-- some constants people might like to tweak Text_color = {r=0, g=0, b=0} Cursor_color = {r=1, g=0, b=0} Stroke_color = {r=0, g=0, b=0} Current_stroke_color = {r=0.7, g=0.7, b=0.7} -- in process of being drawn Current_name_background_color = {r=1, g=0, b=0, a=0.1} -- name currently being edited Focus_stroke_color = {r=1, g=0, b=0} -- what mouse is hovering over Highlight_color = {r=0.7, g=0.7, b=0.9} -- selected text Icon_color = {r=0.7, g=0.7, b=0.7} -- color of current mode icon in drawings Help_color = {r=0, g=0.5, b=0} Help_background_color = {r=0, g=0.5, b=0, a=0.1} Margin_top = 15 Margin_left = 25 Margin_right = 25 Drawing_padding_top = 10 Drawing_padding_bottom = 10 Drawing_padding_height = Drawing_padding_top + Drawing_padding_bottom Same_point_distance = 4 -- pixel distance at which two points are considered the same edit = {} function edit.new(top, left, right, bottom, font, font_height) local result = { top = math.floor(top), left = math.floor(left), right = math.floor(right), bottom = math.floor(bottom), width = right-left, font = font, font_height = font_height, line_height = math.floor(font_height*1.3), -- The editor is for editing an array of lines. A line is either text or a drawing. -- The array of lines can never be empty; there must be at least one line for positioning a cursor at. -- -- A text line is a table with: -- mode = 'text', -- string data, -- A drawing line is a table with: -- mode = 'drawing' -- a (h)eight, -- an array of points, and -- an array of shapes -- A shape is a table containing: -- a mode -- an array points for mode 'freehand' (raw x,y coords; freehand drawings don't pollute the points array of a drawing) -- an array vertices for mode 'polygon', 'rectangle', 'square' -- p1, p2 for mode 'line' -- center, radius for mode 'circle' -- center, radius, start_angle, end_angle for mode 'arc' -- Unless otherwise specified, coord fields are normalized; a drawing is always 256 units wide -- The field names are carefully chosen so that switching modes in midstream -- remembers previously entered points where that makes sense. lines = {{mode='text', data=''}}, -- array of lines -- We need to track a couple of _locations_: screen_top = nil, -- location at top of screen, to start drawing from cursor = nil, -- location where editing will occur -- Valid location values: -- {mode='text', line=, pos=} -- {mode='drawing', line=} selection1 = {}, -- some extra state to compute selection between mouse press and release old_cursor1 = nil, old_selection1 = nil, mousepress_shift = nil, current_drawing_mode = 'line', -- one of the available shape modes previous_drawing_mode = nil, -- extra state for some ephemeral modes like moving/deleting/naming points filename = love.filesystem.getSourceBaseDirectory()..'/lines.txt', -- '/' should work even on Windows next_save = nil, -- undo history = {}, next_history = 1, drawing_before = nil, -- extra state for drawing operations -- search search_term = nil, search_backup = nil, -- stuff to restore when cancelling search } return result end function edit.scroll_to_top(editor) assert(#editor.lines > 0) if editor.lines[1].mode == 'text' then editor.screen_top = {mode='text', line=1, pos=1} editor.cursor = {mode='text', line=1, pos=1} else editor.screen_top = {mode='drawing', line=1} editor.cursor = {mode='drawing', line=1} end end function edit.valid_loc(editor, loc) assert(#editor.lines > 0) if loc == nil then return end if loc.line > #editor.lines then return end return editor.lines[loc.line].mode == loc.mode end function edit.draw(editor) editor.button_handlers = {} love.graphics.setFont(editor.font) local cursor_or_mouse_loc = editor.cursor if editor.mouse_down then local loc = edit.mouse_loc(editor) if loc.mode == 'text' then cursor_or_mouse_loc = loc end end local y = editor.top for line_index, line in array.each(editor.lines, editor.screen_top.line) do local rect if line_index == editor.screen_top.line then rect = edit.get_rect(editor, editor.screen_top) else rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1}) end if rect.screen_line_rects then -- text line if #line.data == 0 then -- empty line; just add a button to insert a new drawing button(editor, 'draw', {x=editor.left-Margin_left+4, y=y+4, w=12,h=12, bg={r=1,g=1,b=0}, icon = icon.insert_drawing, onpress1 = function() editor.drawing_before = snapshot(editor, line_index-1, line_index) table.insert(editor.lines, line_index, {mode='drawing', y=y, h=256/2, points={}, shapes={}, pending={}}) editor.cursor = {mode='drawing', line=line_index} if line_index == editor.screen_top.line then editor.screen_top = {mode='drawing', line=line_index} end schedule_save(editor) record_undo_event(editor, {before=editor.drawing_before, after=snapshot(editor, line_index-1, line_index+1)}) editor.drawing_before = nil end, }) else for _,s in ipairs(rect.screen_line_rects) do for _,c in ipairs(s.char_rects) do if in_selection(editor, line_index, c.pos, cursor_or_mouse_loc) or in_search(editor, line_index, c.pos) then App.color(Highlight_color) love.graphics.rectangle('fill', editor.left+c.x, y+c.y, c.dx,c.dy) end if c.data then App.color(Text_color) love.graphics.print(c.data, editor.left+c.x, y+c.y) end if line_index == editor.cursor.line then if c.pos == editor.cursor.pos then Text.draw_cursor(editor, editor.left+c.x, y+c.y) end end end end end -- draw cursor if it's at end of line if line_index == editor.cursor.line and editor.cursor.pos == utf8.len(line.data)+1 then local s = rect.screen_line_rects local c = s[#s].char_rects Text.draw_cursor(editor, editor.left+c[#c].x, y+c[#c].y) end else -- drawing Drawing.draw(editor, line_index, y+Drawing_padding_top) end y = y + rect.dy if y + editor.line_height > editor.bottom then break end end if editor.search_term then Text.draw_search_bar(editor) end end function edit.update(editor, dt) Drawing.update(editor, dt) if editor.next_save and editor.next_save < Current_time then save_to_disk(editor) editor.next_save = nil end end function schedule_save(editor) if editor.next_save == nil then editor.next_save = Current_time + 3 -- short enough that you're likely to still remember what you did end end function edit.quit(editor) -- make sure to save before quitting if editor.next_save then save_to_disk(editor) -- give some time for the OS to flush everything to disk love.timer.sleep(0.1) end end function edit.mouse_press(editor, mx,my, mouse_button) if editor.search_term then return end editor.mouse_down = mouse_button if mouse_press_consumed_by_any_button(editor, mx,my, mouse_button) then -- press on a button and it returned 'true' to short-circuit return end local loc = edit.to_loc(editor, mx,my) if loc == nil then return -- don't move cursor end editor.cursor = loc if loc.mode == 'text' then -- prepare for a drag selecting text -- delicate dance between cursor/selection and old cursor/selection -- scenarios: -- regular press+release: sets cursor, clears selection -- shift press+release: -- sets selection to old cursor if not set otherwise leaves it untouched -- sets cursor -- press and hold to start a selection: sets selection on press, cursor on release -- press and hold, then press shift: ignore shift -- i.e. mouse_release should never look at shift state editor.old_cursor1 = editor.cursor editor.old_selection1 = editor.selection1 editor.mousepress_shift = shift_down() editor.selection1 = deepcopy(loc) else editor.drawing_before = snapshot(editor, loc.line) Drawing.mouse_press(editor, loc.line, mx,my, mouse_button) end end function edit.mouse_release(editor, mx,my, mouse_button) if editor.search_term then return end editor.mouse_down = nil local loc = edit.to_loc(editor, mx,my) if loc.mode == 'text' then editor.cursor = loc edit.clean_up_mouse_press(editor) else editor.selection1 = {} Drawing.mouse_release(editor, mx,my, mouse_button) schedule_save(editor) if editor.drawing_before then record_undo_event(editor, {before=editor.drawing_before, after=snapshot(editor, editor.cursor.line)}) editor.drawing_before = nil end end end function edit.clean_up_mouse_press(editor) if editor.mousepress_shift then if editor.old_selection1.line == nil then editor.selection1 = editor.old_cursor1 else editor.selection1 = editor.old_selection1 end end editor.old_cursor1, editor.old_selection1, editor.mousepress_shift = nil if edit.eq(editor.cursor, editor.selection1) then editor.selection1 = {} end end function edit.mouse_wheel_move(editor, dx,dy) if dy > 0 then editor.cursor = deepcopy(editor.screen_top) for i=1,math.floor(dy) do Text.up(editor) end elseif dy < 0 then editor.cursor = edit.to_loc(editor, editor.left, editor.bottom-1) assert(editor.cursor) for i=1,math.floor(-dy) do Text.down(editor) end end end function edit.text_input(editor, t) if editor.search_term then editor.search_term = editor.search_term..t Text.search_next(editor) return end if edit.to_coord(editor, editor.cursor) == nil then return end -- cursor is off screen -- to be precise, the top-left corner of the cursor is off screen -- for drawings the cursor is large, which can still be a bit strange -- large selections can also be strange, though the actual cursor would still be small if editor.cursor.mode == 'drawing' and editor.current_drawing_mode == 'name' then -- TODO: there's a bug here where the point being named may be off screen -- To hit that bug you'd have to press C-n to go into point naming mode -- while the cursor is on screen, then move the page somehow without going -- out of naming mode. local before = snapshot(editor, editor.cursor.line) local drawing = editor.lines[editor.cursor.line] local p = drawing.points[drawing.pending.target_point] p.name = p.name..t record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)}) else Text.text_input(editor, t) end schedule_save(editor) end function edit.keychord_press(editor, chord, key) local cursor_on_screen = edit.to_coord(editor, editor.cursor) if editor.selection1.line and editor.cursor.mode == 'text' and cursor_on_screen and -- printable character created using shift key => delete selection -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys) (not shift_down() or utf8.len(key) == 1) and chord ~= 'C-a' and chord ~= 'C-c' and chord ~= 'C-x' and chord ~= 'backspace' and chord ~= 'delete' and chord ~= 'C-z' and chord ~= 'C-y' and not is_cursor_movement(key) then Text.delete_selection_and_record_undo_event(editor) end if editor.search_term then if chord == 'escape' then editor.search_term = nil editor.cursor = editor.search_backup.cursor editor.screen_top = editor.search_backup.screen_top editor.search_backup = nil elseif chord == 'return' then editor.search_term = nil editor.search_backup = nil elseif chord == 'backspace' then local len = utf8.len(editor.search_term) local byte_offset = Text.offset(editor.search_term, len) editor.search_term = string.sub(editor.search_term, 1, byte_offset-1) editor.cursor = deepcopy(editor.search_backup.cursor) editor.screen_top = deepcopy(editor.search_backup.screen_top) Text.search_next(editor) elseif chord == 'down' then Text.right(editor) Text.search_next(editor) elseif chord == 'up' then Text.search_previous(editor) end return elseif chord == 'C-f' then editor.search_term = '' editor.search_backup = { cursor=deepcopy(editor.cursor), screen_top=deepcopy(editor.screen_top), } -- zoom elseif chord == 'C-=' then edit.update_font_settings(editor, editor.font_height+2) elseif chord == 'C--' then if editor.font_height > 2 then edit.update_font_settings(editor, editor.font_height-2) end elseif chord == 'C-0' then edit.update_font_settings(editor, 20) -- undo elseif chord == 'C-z' then local event = undo_event(editor) if event then local src = event.before editor.screen_top = deepcopy(src.screen_top) editor.cursor = deepcopy(src.cursor) editor.selection1 = deepcopy(src.selection) patch(editor.lines, event.after, event.before) schedule_save(editor) end elseif chord == 'C-y' then local event = redo_event(editor) if event then local src = event.after editor.screen_top = deepcopy(src.screen_top) editor.cursor = deepcopy(src.cursor) editor.selection1 = deepcopy(src.selection) patch(editor.lines, event.before, event.after) schedule_save(editor) end -- clipboard elseif chord == 'C-a' and cursor_on_screen then editor.selection1 = {line=1, pos=1} editor.cursor = {mode='text', line=#editor.lines, pos=utf8.len(editor.lines[#editor.lines].data)+1} elseif chord == 'C-c' then local s = Text.selection(editor) if s then love.system.setClipboardText(s) end elseif chord == 'C-x' and cursor_on_screen then local s = Text.cut_selection_and_record_undo_event(editor) if s then love.system.setClipboardText(s) end schedule_save(editor) elseif chord == 'C-v' and cursor_on_screen then -- We don't have a good sense of when to scroll, so we'll be conservative -- and sometimes scroll when we didn't quite need to. local before_line = editor.cursor.line local before = snapshot(editor, before_line) local clipboard_data = love.system.getClipboardText() for _,code in utf8.codes(clipboard_data) do local c = utf8.char(code) if c == '\n' then Text.insert_return(editor) else Text.insert_at_cursor(editor, c) end end maybe_snap_cursor_to_bottom_of_screen(editor) record_undo_event(editor, {before=before, after=snapshot(editor, before_line, editor.cursor.line)}) schedule_save(editor) -- dispatch to drawing or text elseif love.mouse.isDown(1) or chord:sub(1,2) == 'C-' then if editor.cursor.mode == 'drawing' then local before = snapshot(editor, editor.cursor.line) Drawing.keychord_press(editor, chord) record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)}) schedule_save(editor) end elseif chord == 'escape' and not love.mouse.isDown(1) then for _,line in ipairs(editor.lines) do if line.mode == 'drawing' then line.show_help = false end end elseif editor.cursor.mode == 'drawing' and editor.current_drawing_mode == 'name' then if chord == 'return' then editor.current_drawing_mode = editor.previous_drawing_mode editor.previous_drawing_mode = nil else local before = snapshot(editor, editor.cursor.line) local drawing = editor.lines[editor.cursor.line] local p = drawing.points[drawing.pending.target_point] if chord == 'escape' then p.name = nil record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)}) elseif chord == 'backspace' then local len = utf8.len(p.name) if len > 0 then local byte_offset = Text.offset(p.name, len-1) if len == 1 then byte_offset = 0 end p.name = string.sub(p.name, 1, byte_offset) record_undo_event(editor, {before=before, after=snapshot(editor, editor.cursor.line)}) end end end schedule_save(editor) elseif cursor_on_screen then Text.keychord_press(editor, chord) end end function edit.key_release(editor, key, scancode) end function edit.update_font_settings(editor, font_height) editor.font_height = font_height editor.font = love.graphics.newFont(editor.font_height) editor.line_height = math.floor(font_height*1.3) end