local utf8 = require 'utf8' local array = require 'array' local my_utf8 = require 'my_utf8' local t = require 'utils' local rects = require 'rects' local Loc = require 'loc' local move = require 'move' local keychord = require 'keychord' local edit = {} local I = {} -- for file-local helpers, so I can refer to them before defining them edit.internal = I local _ -- idiom for unused variables -- some constants people might like to tweak Cursor_color = {1, 0, 0} -- available to app; indexed by position rather than r/g/b to work with select_rgb local Highlight_color = {0.7, 0.7, 0.9, 0.4} -- selected text Line_number_width = 3 -- in ems; available to app local Line_number_color = {0.4, 0.4, 0.4} local Font_height = require 'font_height' Hand_icon = love.mouse.getSystemCursor('hand') Arrow_icon = love.mouse.getSystemCursor('arrow') function edit.is_this_love_version_supported() local supported_versions = { ['11.5']=true, ['11.4']=true, ['11.3']=true, ['11.2']=true, ['11.1']=true, ['11.0']=true, ['12.0']=true, } return supported_versions[edit.love_version()] end function edit.preferred_love_version() return '11.5' end function edit.love_version() local major_version, minor_version = love.getVersion() return major_version..'.'..minor_version end local Last_focus_time, Last_resize_time Cursor_time = 0 -- available to app -- initialize editor -- this should happen only once regardless of how many editors you create function edit.load() -- for hysteresis in a few places Last_focus_time = 0 -- https://love2d.org/forums/viewtopic.php?p=249700 Last_resize_time = 0 -- blinking cursor (I assume you never show the cursor in more than one editor) Cursor_time = 0 end function edit.new_from_defaults(top, left, right, bottom) local font = love.graphics.newFont(Font_height) return edit.new(top, left, right, bottom, font, Font_height) end function edit.new_from_settings(settings, top, left, right, bottom) local font = love.graphics.newFont(settings.font_height) local editor = edit.new(top, left, right, bottom, font, settings.font_height) edit.load_file(editor, settings.filename) editor.screen_top = settings.screen_top editor.cursor = settings.cursor if not I.valid_loc(editor, editor.screen_top) or not I.valid_loc(editor, editor.cursor) then I.scroll_to_top(editor) end return editor end function edit.resize(editor, w, h, right, bottom) editor.right = right editor.width = editor.right-editor.left editor.bottom = bottom Last_resize_time = Current_time end function edit.settings(editor) return { font_height=editor.font_height, filename=editor.filename, screen_top=editor.screen_top, cursor=editor.cursor } end function edit.load_file(editor, filename) editor.filename = filename editor.lines = {} if love.filesystem.getInfo(filename) then for line in love.filesystem.lines(editor.filename) do table.insert(editor.lines, {data=line}) end end if #editor.lines == 0 then table.insert(editor.lines, {data=''}) end I.scroll_to_top(editor) -- clear out stale locations colorize.all(editor) end function edit.load_array(editor, lines) editor.lines = {} assert(#lines > 0) for _, line in ipairs(lines) do table.insert(editor.lines, {data=line}) end colorize.all(editor) end function edit.focus(in_focus) if in_focus then Last_focus_time = Current_time end end function edit.update_all(dt) Current_time = Current_time + dt Cursor_time = Cursor_time + dt end 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), indent_wrapped_lines = nil, -- The editor is for editing an array of lines. -- The array of lines can never be empty; there must be at least one line for positioning a cursor at. lines = {{data=''}}, -- array of strings -- We need to track a couple of _locations_: screen_top = {line=1, pos=1}, -- location at top of screen, to start drawing from cursor = {line=1, pos=1}, -- location where editing will occur selection = {}, filename = love.filesystem.getSourceBaseDirectory()..'/lines.txt', -- '/' should work even on Windows modified = nil, -- undo history = {}, next_history = 1, -- search search_term = nil, search_backup = nil, -- stuff to restore when cancelling search -- touch features current_touch = nil, inertial_scroll = {}, } return result end function edit.draw(editor, fg, hide_cursor, show_line_numbers, cursor_color) if fg and fg.r then fg = {fg.r, fg.g, fg.b} end -- old interface for compatibility local y = editor.top for line_index = editor.screen_top.line, #editor.lines do local loc assert(editor.lines[line_index].data, 'each line in editor should contain a field called .data containing a string (use edit.load_array instead of setting edit.lines directly)') if line_index == editor.screen_top.line then loc = editor.screen_top else loc = {line=line_index, pos=1} end local rect = rects.compute(editor, loc, editor.bottom - y) if show_line_numbers and loc.pos == 1 then local w = Line_number_width * editor.font:getWidth('m') love.graphics.setColor(Line_number_color) love.graphics.print(loc.line, editor.left-w+10, y) end for _,s in ipairs(rect.screen_line_rects) do --? love.graphics.setColor(0.8, 0.8, 0.8) --? love.graphics.rectangle('fill', editor.left+s.x,y+s.y, s.dx,s.dy) for _,c in ipairs(s.char_rects) do --? love.graphics.setColor(0.8, 0.9, 0.8) --? love.graphics.rectangle('fill', editor.left+c.x,y+c.y, c.dx-2,c.dy-1) if c.show_cursor then -- only the primary place where the cursor can be is available for highlight if I.in_selection(editor, line_index, c.pos, editor.cursor) or I.in_search(editor, line_index, c.pos) then love.graphics.setColor(Highlight_color) love.graphics.rectangle('fill', editor.left+c.x, y+c.y, c.dx,c.dy) end end if c.draw then for _,s in ipairs(c.draw) do I.draw_shape(s, editor.left, y, fg) end end if c.data and not c.conceal then if fg == nil then if c.pos then love.graphics.setColor(c.fg or editor.colors[line_index][c.pos]) else love.graphics.setColor(c.fg) end else love.graphics.setColor(c.fg or fg) end love.graphics.print(c.data, editor.left+c.x, y+c.y) end if not hide_cursor and line_index == editor.cursor.line then if c.pos == editor.cursor.pos and c.show_cursor then I.draw_text_cursor(editor, editor.left+c.x, y+c.y, c.dy, cursor_color) end end end end y = y + rect.dy if y + editor.line_height > editor.bottom then break end end I.draw_selection_handles(editor) if editor.search_term then I.draw_search_bar(editor) end end -- scenarios: -- shift down, mouse click -> select text -- shift up, mouse click -> move cursor -- shift down, mouse click, mouse click -> select text from cursor at start to cursor at end -- mouse down, shift down, mouse up -> select text from mouse press to mouse release -- drag -> select text from mouse press to mouse release -- It looks like all this can be handled by setting selection on press and -- cursor while mouse button is down. function edit.mouse_press(editor, mx,my, mouse_button, is_touch, presses) if is_touch then return end Cursor_time = 0 -- ensure cursor is visible immediately after it moves if mouse_button ~= 1 then return end if editor.search_term then return end if keychord.shift_down() then if editor.selection.line == nil then -- mouse down, shift down; set selection to old cursor editor.selection = editor.cursor end else -- mouse down, shift up: set selection to current cursor editor.selection = move.to_loc(editor, mx,my) end editor.cursor = move.to_loc(editor, mx,my) end function edit.mouse_move(editor, mx,my, dx,dy, is_touch) if is_touch then return end Cursor_time = 0 -- ensure cursor is visible immediately after it moves if love.mouse.isDown(1) then editor.cursor = move.to_loc(editor, mx,my) end end function edit.mouse_release(editor, mx,my, mouse_button, is_touch, presses) if is_touch then return end if Loc.eq(editor.cursor, editor.selection) then editor.selection = {} end end Device_has_touch = false function edit.touch_press(editor, touch_id, tx,ty, dx,dy, pressure) Device_has_touch = true if editor.search_term then return end if editor.current_touch then return end -- no multi-touch support so far if editor.selection.line then if I.on_selection_handle(editor, editor.selection, tx,ty) then editor.current_touch = { id=touch_id, on_selection=true, } elseif I.on_selection_handle(editor, editor.cursor, tx,ty) then editor.current_touch = { id=touch_id, on_cursor=true, } else -- touch outside handles editor.selection = {} end return end editor.current_touch = { id=touch_id, start_time=Current_time, start_x=tx, start_y=ty, prev_x=nil, prev_y=nil, current_x=tx, current_y=ty, has_moved=false, prev_move_time=nil, } end function edit.touch_move(editor, touch_id, tx,ty, dx,dy, pressure) if not editor.current_touch then return end if touch_id ~= editor.current_touch.id then return end local touch = editor.current_touch if touch.on_selection then I.move_selection_handle(editor, editor.selection, tx, ty) return elseif touch.on_cursor then I.move_selection_handle(editor, editor.cursor, tx, ty) return end touch.prev_x = touch.current_x or touch.start_x touch.prev_y = touch.current_y or touch.start_y touch.current_x = tx touch.current_y = ty local distance_sq = (tx-touch.start_x)^2 + (ty-touch.start_y)^2 if distance_sq > 10*10 then touch.has_moved = true local time_delta = Current_time - (touch.prev_move_time or touch.start_time) if time_delta > 0 then touch.velocity_y = -(ty - touch.prev_y) / time_delta end touch.prev_move_time = Current_time -- scroll while dragging local scroll_dy = -(ty - touch.prev_y) * I.inertial_scroll_sensitivity I.apply_scroll(editor, scroll_dy) end end function edit.touch_release(editor, touch_id, tx,ty, dx,dy, pressure) if not editor.current_touch then return end if touch_id == editor.current_touch.id then local touch = editor.current_touch if touch.on_selection or touch.on_cursor then -- ensure selection is at start and cursor at end for next touch assert(editor.selection) if Loc.lt(editor.cursor, editor.selection) then editor.selection, editor.cursor = editor.cursor, editor.selection I.init_selection_handle(editor, editor.selection, -1) I.init_selection_handle(editor, editor.cursor, 0) end elseif not touch.has_moved then love.keyboard.setTextInput(true) local touch_duration = Current_time - touch.start_time if touch_duration < 0.3 then editor.cursor = move.to_loc(editor, touch.start_x, touch.start_y) editor.selection = {} else -- long touch: go into selection mode I.start_text_selection(editor, touch.start_x, touch.start_y) end else -- drag: scroll with inertial momentum I.start_inertial_scroll(editor, touch.velocity_y) end end editor.current_touch = nil end -- inertial scroll I.inertial_scroll_sensitivity = 1.0 I.inertial_scroll_velocity_threshold = 10 I.inertial_scroll_velocity_multiplier = 1.0 I.inertial_scroll_deceleration = 450 function I.start_inertial_scroll(editor, velocity_y) if math.abs(velocity_y) > I.inertial_scroll_velocity_threshold then editor.inertial_scroll = { active = true, velocity_y = velocity_y * I.inertial_scroll_velocity_multiplier, accumulator = nil, } end end function edit.update_inertial_scroll(editor, dt) local s = editor.inertial_scroll if not s.active then return end I.apply_scroll(editor, s.velocity_y*dt) -- decelerate if math.abs(s.velocity_y) < I.inertial_scroll_deceleration*dt then -- s.velocity_y about to switch sign editor.inertial_scroll = {} return end local velocity_sign = (s.velocity_y > 0 and 1 or -1) local decel = velocity_sign * I.inertial_scroll_deceleration s.velocity_y = s.velocity_y - decel*dt if math.abs(s.velocity_y) < 1 then editor.inertial_scroll = {} return end end function I.apply_scroll(editor, dy) if dy == 0 then return end local s = editor.inertial_scroll if s.accumulator == nil then s.accumulator = 0 end s.accumulator = s.accumulator + dy local lines_to_scroll = math.floor(math.abs(s.accumulator) / editor.line_height) if lines_to_scroll == 0 then return end local direction = s.accumulator > 0 and 1 or -1 local scroll_fn if direction > 0 then scroll_fn = move.internal.loc_down else scroll_fn = move.internal.loc_up end local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height) if tmp then editor.screen_top = tmp editor.cursor = t.deepcopy(editor.screen_top) s.accumulator = s.accumulator - direction*lines_to_scroll*editor.line_height else error('bug: too fast') end end function edit.maybe_stop_inertial_scroll(editor) if editor.current_touch then return false end -- current touch is in progress if not editor.inertial_scroll.active then return false end editor.inertial_scroll = {} return true end -- Text selection with draggable handles function I.start_text_selection(editor, touch_x, touch_y) local loc = move.to_loc(editor, touch_x, touch_y) if not loc then return end local curr_line = editor.lines[loc.line].data local word_start, word_end = I.find_word_boundaries(curr_line, loc.pos) editor.selection = {line = loc.line, pos = word_start} editor.cursor = {line = loc.line, pos = word_end} I.init_selection_handle(editor, editor.selection, -1) I.init_selection_handle(editor, editor.cursor, 0) end function I.find_word_boundaries(s, pos) assert(pos <= utf8.len(s)+1) local word_start, word_end = pos, pos while true do assert(word_start >= 1) if word_start == 1 then break end if my_utf8.match_at(s, word_start-1, '%s') then break end word_start = word_start-1 end while true do assert(word_end <= utf8.len(s)+1) if word_end > utf8.len(s) then break end if my_utf8.match_at(s, word_end, '%s') then break end word_end = word_end+1 end return word_start, word_end end function I.init_selection_handle(editor, loc, xoff) local x,y = move.to_coord(editor, loc) if not x then loc.handle = nil loc.area = nil else local em_width = editor.font:getWidth('m') loc.handle = {x=x+xoff*em_width, y=y+editor.line_height, dx=em_width, dy=editor.line_height, xoff=xoff} loc.area = {x=x+(xoff-1)*em_width, y=y, dx=3*em_width, dy=3*editor.line_height, xoff=xoff} end end function I.draw_selection_handles(editor) if not Device_has_touch then return end if not editor.selection.line then return end local r = editor.selection.handle if r then love.graphics.setColor(0.6, 0.6, 0.7) love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy) end r = editor.cursor.handle if r then love.graphics.setColor(0.6, 0.6, 0.7) love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy) end if editor.current_touch then if editor.current_touch.on_selection then I.draw_magnifier(editor, editor.selection) elseif editor.current_touch.on_cursor then I.draw_magnifier(editor, editor.cursor) end end end function I.on_selection_handle(editor, loc, x,y) if not loc.area then return false end return move.internal.within_rect(loc.area, x,y) end -- modify loc in place to x,y function I.move_selection_handle(editor, loc, x,y) I.scroll_with_selection_handle(editor, loc, x,y) -- Get screen coordinates for handles local nloc = move.to_loc(editor, x,y) if not nloc then return end I.init_selection_handle(editor, nloc, loc.handle.xoff) loc.line, loc.pos, loc.handle, loc.area = nloc.line, nloc.pos, nloc.handle, nloc.area end -- Scroll if x,y is close to top or bottom margins function I.scroll_with_selection_handle(editor, loc, x,y) local zone1 = 30 -- px local within_zone1_speed = 1 -- lines/frame local outside_zone1_speed = 2 -- lines/frame local scroll_speed = 0 assert(editor.top < editor.bottom) if y < editor.top-zone1 then scroll_speed = -outside_zone1_speed elseif y < editor.top then scroll_speed = -within_zone1_speed elseif y < editor.bottom then loc.scroll_accumulator = nil return elseif y < editor.bottom+zone1 then scroll_speed = within_zone1_speed else assert(y >= editor.bottom+zone1) scroll_speed = outside_zone1_speed end -- TODO: dedup this next bit with I.apply_scroll if loc.scroll_accumulator == nil then loc.scroll_accumulator = 0 end loc.scroll_accumulator = loc.scroll_accumulator + scroll_speed local lines_to_scroll = math.floor(math.abs(loc.scroll_accumulator) / editor.line_height) if lines_to_scroll == 0 then return end local direction = scroll_speed > 0 and 1 or -1 local scroll_fn if direction > 0 then scroll_fn = move.internal.loc_down else scroll_fn = move.internal.loc_up end local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height) if tmp then editor.screen_top = tmp loc.scroll_accumulator = loc.scroll_accumulator - direction*lines_to_scroll*editor.line_height -- update the other handle if loc == editor.cursor then I.init_selection_handle(editor, editor.selection, -1) elseif loc == editor.selection then I.init_selection_handle(editor, editor.cursor, 0) else error('invalid loc') end else error('bug: too fast') end end -- magnify the area around loc -- precondition: loc.handle exists function I.draw_magnifier(editor, loc) local zoom_factor = 2.0 local mag_offset = 60 local mag_height = editor.line_height*zoom_factor local line_text = editor.lines[loc.line].data local chars_around = 5 local start_pos = math.max(1, loc.pos - chars_around) local end_pos = math.min(utf8.len(line_text), loc.pos + chars_around) local mag_text = my_utf8.sub(line_text, start_pos, end_pos+1) local mag_text_width = editor.font:getWidth(mag_text) local mag_width = mag_text_width*zoom_factor -- position magnifier well to the left, for two reasons: -- - assume a right-handed touch -- - avoid the scrollbar on the right local mag_x = editor.left -- position magnifier above if possible, or below local mag_y = loc.handle.y - mag_height - mag_offset if mag_y < editor.top then mag_y = loc.handle.y + mag_offset end -- draw magnifier frame and content love.graphics.setColor(0,0,0) love.graphics.setLineWidth(2) love.graphics.rectangle('fill', mag_x-5, mag_y-5, mag_width+10, mag_height+10, 5,5) love.graphics.setLineWidth(1) love.graphics.setColor(1,1,1) love.graphics.rectangle('fill', mag_x-2, mag_y-2, mag_width+4, mag_height+4) if Loc.lt(editor.cursor, loc) or Loc.lt(editor.selection, loc) then -- loc is at end of selection local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height) I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height) elseif Loc.lt(loc, editor.cursor) or Loc.lt(loc, editor.selection) then -- loc is at start of selection local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height) I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height) end end function I.draw_magnifier_text(s, start_pos, end_pos, x,y, font, background_color, zoom_factor, line_height) local text = my_utf8.sub(s, start_pos, end_pos) local w = font:getWidth(text)*zoom_factor love.graphics.setColor(background_color) love.graphics.rectangle('fill', x,y, w, line_height*zoom_factor) love.graphics.setColor(0,0,0) love.graphics.print(text, x, y, --[[rotation]] 0, zoom_factor) return x+w end function edit.mouse_wheel_move(editor, dx,dy) Cursor_time = 0 -- ensure cursor is visible immediately after it moves if dy > 0 then editor.cursor = t.deepcopy(editor.screen_top) for i=1,math.floor(dy) do move.up_arrow(editor) end elseif dy < 0 then editor.cursor = move.to_loc(editor, editor.left, editor.bottom-1) assert(editor.cursor) for i=1,math.floor(-dy) do move.down_arrow(editor) end end end function I.scroll_to_top(editor) assert(#editor.lines > 0) editor.screen_top = {line=1, pos=1} editor.cursor = {line=1, pos=1} end function I.valid_loc(editor, loc) assert(#editor.lines > 0) if loc == nil then return end if loc.line > #editor.lines then return end if loc.pos > #editor.lines[loc.line].data then return end return true end function I.mark_modified(editor) editor.modified = true end I.buffer_modified_handlers = {} function edit.signal_buffer_modified(editor) for _, handler in ipairs(I.buffer_modified_handlers) do handler(editor) end end table.insert(I.buffer_modified_handlers, I.mark_modified) table.insert(I.buffer_modified_handlers, colorize.all) ---- keyboard handling function edit.text_input(editor, t, readonly) -- ignore events for some time after window in focus (mostly alt-tab) if Current_time < Last_focus_time + 0.01 then return end -- Cursor_time = 0 -- ensure cursor is visible immediately after it moves if love.mouse.isDown(1) then return end if editor.search_term then editor.search_term = editor.search_term..t I.search_next(editor) return end if move.to_coord(editor, editor.cursor) == nil then return end -- cursor is off screen if keychord.any_modifier_down() and key_down(t) then -- The modifiers didn't change the key. Handle it in keychord_press. return end if not readonly then local before = I.snapshot(editor, editor.cursor.line) -- Try to support mobile keyboard swipe/autocomplete/paste as best we -- can. -- -- Mobile paste still works poorly. It's unreliable, often truncates -- large pastes, and inserts newlines twice, first keypress events for -- all characters in a paste and _then_ textinput for all characters. I.insert_text(editor, t) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) edit.signal_buffer_modified(editor) end end function edit.keychord_press(editor, chord, key, scancode, is_repeat, readonly) -- ignore events for some time after window in focus (mostly alt-tab) if Current_time < Last_focus_time + 0.01 then return end -- Cursor_time = 0 -- ensure cursor is visible immediately after it moves local cursor_on_screen = move.to_coord(editor, editor.cursor) local dm = keychord.default_modifier if not readonly and editor.selection.line 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 keychord.shift_down() or utf8.len(key) == 1) and chord ~= dm('a') and chord ~= dm('c') and chord ~= dm('x') and chord ~= 'backspace' and chord ~= 'delete' and chord ~= dm('z') and chord ~= dm('y') and not keychord.is_cursor_movement(key) then I.delete_selection_and_record_undo_event(editor) edit.signal_buffer_modified(editor) -- possibly redundant, but ensure it happens 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 = my_utf8.offset(editor.search_term, len) editor.search_term = string.sub(editor.search_term, 1, byte_offset-1) editor.cursor = t.deepcopy(editor.search_backup.cursor) editor.screen_top = t.deepcopy(editor.search_backup.screen_top) I.search_next(editor) elseif chord == 'down' then if #editor.search_term > 0 then move.right_arrow(editor) I.search_next(editor) end elseif chord == 'up' then I.search_previous(editor) end return elseif chord == dm('f') then editor.search_term = '' editor.search_backup = { cursor=t.deepcopy(editor.cursor), screen_top=t.deepcopy(editor.screen_top), } -- zoom elseif chord == dm('=') then edit.update_font_settings(editor, editor.font_height+2) elseif chord == dm('-') then if editor.font_height > 2 then edit.update_font_settings(editor, editor.font_height-2) end elseif chord == dm('0') then edit.update_font_settings(editor, 20) -- undo elseif not readonly and chord == dm('z') then local event = I.undo_event(editor) if event then local src = event.before editor.screen_top = t.deepcopy(src.screen_top) editor.cursor = t.deepcopy(src.cursor) editor.selection = t.deepcopy(src.selection) I.patch(editor.lines, event.after, event.before) edit.signal_buffer_modified(editor) end elseif not readonly and chord == dm('y') then local event = I.redo_event(editor) if event then local src = event.after editor.screen_top = t.deepcopy(src.screen_top) editor.cursor = t.deepcopy(src.cursor) editor.selection = t.deepcopy(src.selection) I.patch(editor.lines, event.before, event.after) edit.signal_buffer_modified(editor) end -- clipboard elseif chord == dm('a') and cursor_on_screen then editor.selection = {line=1, pos=1} editor.cursor = {line=#editor.lines, pos=utf8.len(editor.lines[#editor.lines].data)+1} elseif chord == dm('c') then local s = I.selection(editor) if s then love.system.setClipboardText(s) end elseif not readonly and chord == dm('x') and cursor_on_screen then local s = I.cut_selection(editor, editor.left, editor.right) if s then love.system.setClipboardText(s) end edit.signal_buffer_modified(editor) elseif not readonly and chord == dm('v') and cursor_on_screen then local before_line = editor.cursor.line local before = I.snapshot(editor, before_line) local clipboard_data = love.system.getClipboardText() I.insert_text(editor, clipboard_data) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)}) edit.signal_buffer_modified(editor) --== shortcuts that mutate text elseif not readonly and chord == 'return' and cursor_on_screen then local before_line = editor.cursor.line local before = I.snapshot(editor, before_line) I.insert_return_at_cursor(editor) move.maybe_snap_cursor_to_bottom_of_screen(editor) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)}) edit.signal_buffer_modified(editor) elseif not readonly and chord == 'tab' and cursor_on_screen then local before = I.snapshot(editor, editor.cursor.line) I.insert_char_at_cursor(editor, '\t') move.maybe_snap_cursor_to_bottom_of_screen(editor) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) edit.signal_buffer_modified(editor) elseif not readonly and chord == 'backspace' and cursor_on_screen then if editor.selection.line then I.delete_selection_and_record_undo_event(editor) edit.signal_buffer_modified(editor) return end if editor.cursor.line == 1 and editor.cursor.pos == 1 then -- no-op return end local before if editor.cursor.pos > 1 then before = I.snapshot(editor, editor.cursor.line) local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos-1) local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) if byte_start then if byte_end then editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end) else editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1) end editor.cursor.pos = editor.cursor.pos-1 end else assert(editor.cursor.line > 1) before = I.snapshot(editor, editor.cursor.line-1, editor.cursor.line) -- join lines editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line-1].data)+1 editor.lines[editor.cursor.line-1].data = editor.lines[editor.cursor.line-1].data..editor.lines[editor.cursor.line].data table.remove(editor.lines, editor.cursor.line) editor.cursor.line = editor.cursor.line-1 end if editor.screen_top.line > #editor.lines then -- line no longer exists editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) elseif Loc.eq(editor.cursor, editor.screen_top) then local len = utf8.len(editor.lines[editor.cursor.line].data) assert(editor.cursor.pos <= len+1) if editor.cursor.pos == len+1 then -- screen line may no longer exist editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) assert(editor.screen_top) end elseif Loc.lt(editor.cursor, editor.screen_top) then move.maybe_snap_cursor_to_top_of_screen(editor) end assert(Loc.le(editor.screen_top, editor.cursor), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(editor.screen_top.line, editor.screen_top.pos or -1, editor.cursor.line, editor.cursor.pos or -1)) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) edit.signal_buffer_modified(editor) elseif not readonly and chord == 'delete' and cursor_on_screen then -- cursor in text line if editor.selection.line then I.delete_selection_and_record_undo_event(editor) edit.signal_buffer_modified(editor) return end local before if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then before = I.snapshot(editor, editor.cursor.line) else before = I.snapshot(editor, editor.cursor.line, editor.cursor.line+1) end if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos+1) if byte_start then if byte_end then editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end) else editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1) end -- no change to editor.cursor.pos end elseif editor.cursor.line < #editor.lines then -- join lines editor.lines[editor.cursor.line].data = editor.lines[editor.cursor.line].data..editor.lines[editor.cursor.line+1].data table.remove(editor.lines, editor.cursor.line+1) end I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) edit.signal_buffer_modified(editor) --== shortcuts that move the cursor elseif chord == 'left' and cursor_on_screen then move.left_arrow(editor) editor.selection = {} elseif chord == 'right' and cursor_on_screen then move.right_arrow(editor) editor.selection = {} elseif chord == 'S-left' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.left_arrow(editor) elseif chord == 'S-right' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.right_arrow(editor) -- C- hotkeys reserved for drawings, so we'll use M- elseif chord == 'M-left' and cursor_on_screen then move.word_left(editor) editor.selection = {} elseif chord == 'M-right' and cursor_on_screen then move.word_right(editor) editor.selection = {} elseif chord == 'M-S-left' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.word_left(editor) elseif chord == 'M-S-right' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.word_right(editor) elseif (chord == 'home' or (OS == 'OS X' and chord == 's-left')) and cursor_on_screen then move.start_of_line(editor) editor.selection = {} elseif (chord == 'end' or (OS == 'OS X' and chord == 's-right')) and cursor_on_screen then move.end_of_line(editor) editor.selection = {} elseif chord == 'S-home' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.start_of_line(editor) elseif chord == 'S-end' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.end_of_line(editor) elseif chord == 'up' and cursor_on_screen then move.up_arrow(editor) editor.selection = {} elseif chord == 'down' and cursor_on_screen then move.down_arrow(editor) editor.selection = {} elseif chord == 'S-up' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.up_arrow(editor) elseif chord == 'S-down' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.down_arrow(editor) elseif chord == 'pageup' and cursor_on_screen then move.pageup(editor) editor.selection = {} elseif chord == 'pagedown' and cursor_on_screen then move.pagedown(editor) editor.selection = {} elseif chord == 'S-pageup' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.pageup(editor) elseif chord == 'S-pagedown' and cursor_on_screen then if editor.selection.line == nil then editor.selection = t.deepcopy(editor.cursor) end move.pagedown(editor) end end function I.insert_text(editor, s) for _,code in utf8.codes(s) do local c = utf8.char(code) if c == '\n' then I.insert_return_at_cursor(editor) else I.insert_char_at_cursor(editor, c) end end move.maybe_snap_cursor_to_bottom_of_screen(editor) end function edit.clear(editor) editor.lines = {{data=''}} editor.cursor = {line=1, pos=1} editor.screen_top = {line=1, pos=1} editor.selection = {} colorize.all(editor) end function edit.key_release(editor, key, scancode) -- ignore events for some time after window in focus (mostly alt-tab) if Current_time < Last_focus_time + 0.01 then return end -- Cursor_time = 0 -- ensure cursor is visible immediately after it moves 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 function I.draw_text_cursor(editor, x, y, dy, cursor_color) -- blink every 0.5s if math.floor(Cursor_time*2)%2 == 0 then love.graphics.setColor(cursor_color or Cursor_color) love.graphics.rectangle('fill', x,y, 3,dy) end end function I.insert_char_at_cursor(editor, t) local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1)..t..string.sub(editor.lines[editor.cursor.line].data, byte_offset) editor.cursor.pos = editor.cursor.pos+1 end function I.insert_return_at_cursor(editor) local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos) table.insert(editor.lines, editor.cursor.line+1, {data=string.sub(editor.lines[editor.cursor.line].data, byte_offset)}) editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1) editor.cursor = {line=editor.cursor.line+1, pos=1} end -- mappings only to non-printable keys; leave out mappings that textinput will handle local Numlock_off = { kp0='insert', kp1='end', kp2='down', kp3='pagedown', kp4='left', -- numpad 5 translates to nothing kp6='right', kp7='home', kp8='up', kp9='pageup', ['kp.']='delete', -- LÖVE handles keypad operators in textinput -- what's with the `kp=` and `kp,` keys? None of my keyboards have one. -- Hopefully LÖVE handles them as well in textinput. kpenter='enter', kpdel='delete', } local Numlock_on = { kpenter='enter', kpdel='delete', } function I.translate_numlock(key) if love.keyboard.isModifierActive('numlock') then return Numlock_on[key] or key else return Numlock_off[key] or key end return key end function I.draw_shape(shape, left, top, fg) love.graphics.setColor(shape.fg or fg) if shape.type == 'rect' then love.graphics.rectangle(shape.mode, left+shape.x, top+shape.y, shape.dx, shape.dy) elseif shape.type == 'line' then love.graphics.line(left+shape.x1, top+shape.y1, left+shape.x2, top+shape.y2) elseif shape.type == 'circle' then love.graphics.circle(shape.mode, left+shape.x, top+shape.y, shape.r) end end ---- helpers for file operations function edit.save_to_disk(editor) if editor.filename == nil then return end local lines = {} for _, line in ipairs(editor.lines) do table.insert(lines, line.data) end local contents = table.concat(lines, '\n') local success, message = love.filesystem.write(editor.filename, contents) if not success then error('failed to write to "'..editor.filename..'": '..message) end editor.modified = nil end ---- helpers for the search bar (C-f) function I.draw_search_bar(editor) local screen_width, screen_height = love.graphics.getDimensions() local h = editor.line_height+2 local y = screen_height-h love.graphics.setColor(0.9,0.9,0.9) love.graphics.rectangle('fill', 0, y-10, screen_width-1, h+8) love.graphics.setColor(0.6,0.6,0.6) love.graphics.line(0, y-10, screen_width-1, y-10) love.graphics.setColor(1,1,1) love.graphics.rectangle('fill', 20, y-6, screen_width-40, h+2, 2,2) love.graphics.setColor(0.6,0.6,0.6) love.graphics.rectangle('line', 20, y-6, screen_width-40, h+2, 2,2) love.graphics.setColor(Foreground_color) love.graphics.print(editor.search_term, 25,y-5) I.draw_text_cursor(editor, 25+editor.font:getWidth(editor.search_term),y-5, editor.line_height) end function I.search_next(editor) if #editor.search_term == 0 then return end local offset -- search current line from cursor local curr_pos = editor.cursor.pos local curr_line = editor.lines[editor.cursor.line].data local curr_offset = my_utf8.offset(curr_line, curr_pos) offset = I.find(curr_line, editor.search_term, curr_offset, --[[literal]] true) if offset then editor.cursor.pos = utf8.len(curr_line, 1, offset) end if offset == nil then -- search lines below cursor for i=editor.cursor.line+1,#editor.lines do local curr_line = editor.lines[i].data offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) if offset then editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} break end end end if offset == nil then -- wrap around for i=1,editor.cursor.line-1 do local curr_line = editor.lines[i].data offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) if offset then editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} break end end end if offset == nil then -- search current line until cursor local curr_line = editor.lines[editor.cursor.line].data offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true) local pos = utf8.len(curr_line, 1, offset) if pos and pos < editor.cursor.pos then editor.cursor.pos = pos end end if offset then move.maybe_snap_cursor_to_bottom_of_screen(editor) else -- roll back editor.cursor = t.deepcopy(editor.search_backup.cursor) editor.screen_top = t.deepcopy(editor.search_backup.screen_top) end end function I.search_previous(editor) if #editor.search_term == 0 then return end local offset -- search current line before cursor local curr_pos = editor.cursor.pos local curr_line = editor.lines[editor.cursor.line].data local curr_offset = my_utf8.offset(curr_line, curr_pos) offset = I.rfind(curr_line, editor.search_term, curr_offset-1, --[[literal]] true) if offset then editor.cursor.pos = utf8.len(curr_line, 1, offset) end if offset == nil then -- search lines above cursor for i=editor.cursor.line-1,1,-1 do local curr_line = editor.lines[i].data offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) if offset then editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} break end end end if offset == nil then -- wrap around for i=#editor.lines,editor.cursor.line+1,-1 do local curr_line = editor.lines[i].data offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) if offset then editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)} break end end end if offset == nil then -- search current line after cursor local curr_line = editor.lines[editor.cursor.line].data offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true) local pos = utf8.len(curr_line, 1, offset) if pos and pos > editor.cursor.pos then editor.cursor.pos = pos end end if offset then move.maybe_snap_cursor_to_top_of_screen(editor) else -- roll back editor.cursor = t.deepcopy(editor.search_backup.cursor) editor.screen_top = t.deepcopy(editor.search_backup.screen_top) end end -- return true if cursor marks start of search term and line_index,pos is in that region function I.in_search(editor, line_index, pos) if editor.search_term == nil then return false end if #editor.search_term == 0 then return false end if line_index ~= editor.cursor.line then return false end return I.find_at(editor.lines[line_index].data, editor.search_term, editor.cursor.pos) and editor.cursor.pos <= pos and pos <= editor.cursor.pos+utf8.len(editor.search_term)-1 end function I.find_at(data, pat, pos) local offset = utf8.offset(data, pos) return data:sub(offset, offset+#pat-1) == pat end function I.find(s, pat, i, plain) if s == nil then return end return s:find(pat, i, plain) end -- TODO: avoid the expensive reverse() operations -- Particularly if we only care about literal matches, we don't need all of string.find function I.rfind(s, pat, i, plain) if s == nil then return end if #pat == 0 then return #s end local rs = s:reverse() local rpat = pat:reverse() if i == nil then i = #s end local ri = #s - i + 1 local rendpos = rs:find(rpat, ri, plain) if rendpos == nil then return nil end local endpos = #s - rendpos + 1 assert (endpos >= #pat, ('rfind: endpos %d should be >= #pat %d at this point'):format(endpos, #pat)) return endpos-#pat+1 end function test_rfind() check_eq(I.rfind('abc', ''), 3, 'empty pattern') check_eq(I.rfind('abc', 'c'), 3, 'final char') check_eq(I.rfind('acbc', 'c', 3), 2, 'previous char') check_nil(I.rfind('abc', 'd'), 'missing char') check_nil(I.rfind('abc', 'c', 2), 'no more char') end ---- helpers for selecting portions of text function I.cut_selection(editor) if editor.selection.line == nil then return end local result = I.selection(editor) I.delete_selection_and_record_undo_event(editor) return result end function I.delete_selection_and_record_undo_event(editor) if editor.selection.line == nil then return end local minl,maxl = t.minmax(editor.selection.line, editor.cursor.line) local before = I.snapshot(editor, minl, maxl) I.delete_selection_without_undo(editor) I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)}) end function I.delete_selection_without_undo(editor) if editor.selection.line == nil then return end -- min,max = sorted(editor.selection,editor.cursor) local minl,minp = editor.selection.line,editor.selection.pos local maxl,maxp = editor.cursor.line,editor.cursor.pos if minl > maxl then minl,maxl = maxl,minl minp,maxp = maxp,minp elseif minl == maxl then if minp > maxp then minp,maxp = maxp,minp end end -- update editor.cursor and editor.selection editor.cursor.line = minl editor.cursor.pos = minp if Loc.lt(editor.cursor, editor.screen_top) then editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left) end editor.selection = {} -- delete everything between min (inclusive) and max (exclusive) local min_offset = my_utf8.offset(editor.lines[minl].data, minp) local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp) if minl == maxl then editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..editor.lines[minl].data:sub(max_offset) return end assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl)) local rhs = editor.lines[maxl].data:sub(max_offset) for i=maxl,minl+1,-1 do table.remove(editor.lines, i) end editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..rhs end function I.selection(editor) if editor.selection.line == nil then return end -- min,max = sorted(editor.selection,editor.cursor) local minl,minp = editor.selection.line,editor.selection.pos local maxl,maxp = editor.cursor.line,editor.cursor.pos if minl > maxl then minl,maxl = maxl,minl minp,maxp = maxp,minp elseif minl == maxl then if minp > maxp then minp,maxp = maxp,minp end end local min_offset = my_utf8.offset(editor.lines[minl].data, minp) local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp) if minl == maxl then return editor.lines[minl].data:sub(min_offset, max_offset-1) end assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl)) local result = {editor.lines[minl].data:sub(min_offset)} for i=minl+1,maxl-1 do table.insert(result, editor.lines[i].data) end table.insert(result, editor.lines[maxl].data:sub(1, max_offset-1)) return table.concat(result, '\n') end function I.in_selection(editor, line_index, pos, cursor) if editor.selection.line == nil then return false end local curr = {line=line_index, pos=pos} if Loc.eq(cursor, editor.selection) then return false elseif Loc.lt(cursor, editor.selection) then return Loc.le(cursor, curr) and Loc.lt(curr, editor.selection) elseif Loc.lt(editor.selection, cursor) then return Loc.le(editor.selection, curr) and Loc.lt(curr, cursor) end end ---- undo/redo -- undo/redo by managing the sequence of events in the current session -- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu -- makes a copy of lines on every single keystroke; will be inefficient with really long lines. -- TODO: highlight stuff inserted by any undo/redo operation -- TODO: coalesce multiple similar operations function I.record_undo_event(editor, data) editor.history[editor.next_history] = data editor.next_history = editor.next_history+1 for i=editor.next_history,#editor.history do editor.history[i] = nil end end function I.undo_event(editor) if editor.next_history > 1 then --? print('moving to history', editor.next_history-1) editor.next_history = editor.next_history-1 local result = editor.history[editor.next_history] return result end end function I.redo_event(editor) if editor.next_history <= #editor.history then --? print('restoring history', editor.next_history+1) local result = editor.history[editor.next_history] editor.next_history = editor.next_history+1 return result end end -- Copy all relevant global editor. -- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories. function I.snapshot(editor, s,e) -- Snapshot everything by default, but subset if requested. assert(s, 'failed to snapshot operation for undo history') if e == nil then e = s end assert(#editor.lines > 0, 'failed to snapshot operation for undo history') if s < 1 then s = 1 end if s > #editor.lines then s = #editor.lines end if e < 1 then e = 1 end if e > #editor.lines then e = #editor.lines end -- compare with edit.new local event = { screen_top=t.deepcopy(editor.screen_top), selection=t.deepcopy(editor.selection), cursor=t.deepcopy(editor.cursor), lines={}, start_line=s, end_line=e, -- no filename; undo history is cleared when filename changes } for i=s,e do table.insert(event.lines, t.deepcopy(editor.lines[i])) end return event end function I.patch(lines, from, to) --? if #from.lines == 1 and #to.lines == 1 then --? assert(from.start_line == from.end_line) --? assert(to.start_line == to.end_line) --? assert(from.start_line == to.start_line) --? lines[from.start_line] = to.lines[1] --? return --? end assert(from.start_line == to.start_line, 'failed to patch undo operation') for i=from.end_line,from.start_line,-1 do table.remove(lines, i) end assert(#to.lines == to.end_line-to.start_line+1, 'failed to patch undo operation') for i=1,#to.lines do table.insert(lines, to.start_line+i-1, to.lines[i]) end end return edit