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 = {} local I = {} move.internal = I ---- moving around the editor in terms of locations (loc) -- -- locations within text lines look like this: -- {line=, pos=} -- (pos counts in utf8 codepoints starting from 1) -- -- all movements are built primarily using the following primitives, defined -- further down: -- - to_loc: (x, y) -> loc -- identify the location at pixel coordinates (x,y) on screen -- returns nil if (x,y) is not on screen -- - to_coord: loc -> x, y -- identify the top-left coordinate on screen of location loc -- returns nil if loc is not on screen -- - loc_down: loc, dy -> loc -- find the location at the start of a screen line dy pixels down from loc -- returns bottom of file if we hit it -- - loc_up: loc, dy -> loc -- find the location at the start of a screen line dy pixels up from loc -- returns top of file if we hit it -- - loc_hor: loc, x -> loc -- find the location at x on the same screen line as loc -- -- I have tried to make these definitions as clear as possible while being fast enough. -- My mental model for trading off performance for clarity: -- - any computation limited to the number of characters a screen can show -- will be an order of magnitude faster than it needs to be to draw 30 -- frames per second. -- So I don't mind doing up to 5 scans per interactive operation if that -- makes the code clearer. -- - no caching across frames; it makes the code less clear and has also -- caused bugs. -- - short-lived memory allocations that live within a single frame are cheap -- -- This API was non-trivial to arrive at. It seems promising for any -- pixel-based editor using proportional fonts. It seems independent of the -- data structures the editor uses, whether arrays as here or ropes or gap -- buffers, though I cheat a bit and use knowledge of the data structures -- where it saves me a scan or two. function move.up_arrow(editor) local x, _ = move.to_coord(editor, editor.cursor) -- scan editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan assert(editor.cursor) editor.cursor = I.loc_hor(editor, editor.cursor, x) -- 0-1 scan assert(editor.cursor) move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans end -- 3-5 scans function move.down_arrow(editor) local x, _ = move.to_coord(editor, editor.cursor) -- scan local dy0 = rects.height_of_screen_line(editor, editor.cursor) editor.cursor = I.loc_down(editor, editor.cursor, dy0) -- scan assert(editor.cursor) editor.cursor = I.loc_hor(editor, editor.cursor, x) -- 0-1 scan assert(editor.cursor) move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans end -- 2-5 scans function move.left_arrow(editor) I.left_arrow_without_scroll(editor) -- 0-2 scans move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans end -- 1-4 scans function I.left_arrow_without_scroll(editor) if editor.cursor.pos and editor.cursor.pos > 1 then editor.cursor.pos = editor.cursor.pos-1 elseif editor.cursor.line > 1 then editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan assert(editor.cursor) editor.cursor = I.loc_hor(editor, editor.cursor, editor.right) -- 0-1 scan assert(editor.cursor) end end -- 0-2 scans -- without cheating --? function I.left_arrow_without_scroll(editor) --? local x, _ = move.to_coord(editor, editor.cursor) -- scan --? if x <= editor.left then -- 0-2 scans --? editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan --? editor.cursor = I.loc_hor(editor, editor.cursor, editor.right) -- 0-1 scan --? else --? editor.cursor = I.loc_hor(editor, editor.cursor, x-1) -- 0-1 scan --? end --? end -- 1-3 scans function move.right_arrow(editor) I.right_arrow_without_scroll(editor) -- 0-2 scans move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans end -- 0-4 scans function I.right_arrow_without_scroll(editor) if editor.cursor.pos and editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then editor.cursor.pos = editor.cursor.pos+1 else local _, y = move.to_coord(editor, editor.cursor) -- scan local dy = rects.height_of_screen_line(editor, editor.cursor) local new_cursor = I.loc_down(editor, editor.cursor, dy) -- scan if Loc.lt(editor.cursor, new_cursor) then -- there's further down to go editor.cursor = new_cursor end end end -- 0-2 scans function move.pageup(editor) -- Naively we'd just scroll up by editor height. However: -- 1. Some portion of it that doesn't divide in line height is useless local h = editor.line_height local height = math.floor((editor.bottom-editor.top)/h)*h -- 2. We want to display one line of overlap between the current page and previous. -- I believe 1 and 2 are distinct issues. Certainly I need to do both for -- pageup and pagedown to be precisely symmetrical at all combinations of -- screen heights and line heights. editor.screen_top = I.loc_up(editor, editor.screen_top, height - h) -- scan assert(editor.screen_top) editor.cursor = t.deepcopy(editor.screen_top) assert(editor.cursor) end -- 1 scan function move.pagedown(editor) editor.screen_top = I.loc_down(editor, editor.screen_top, editor.bottom - editor.top - editor.line_height) -- scan assert(editor.screen_top) editor.cursor = t.deepcopy(editor.screen_top) assert(editor.cursor) end -- 1 scan function move.start_of_line(editor) editor.cursor.pos = 1 move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans end -- 1-2 scan function move.end_of_line(editor) editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line].data) + 1 move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-1 scan end -- 0-1 scan function move.word_left(editor) -- skip some whitespace while true do if editor.cursor.pos == nil or editor.cursor.pos == 1 then break -- line boundary is always whitespace end if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%S') then break end I.left_arrow_without_scroll(editor) end -- skip some non-whitespace while true do I.left_arrow_without_scroll(editor) if editor.cursor.pos == nil or editor.cursor.pos == 1 then break end assert(editor.cursor.pos > 1, 'bumped up against start of line') if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%s') then break end end move.maybe_snap_cursor_to_top_of_screen(editor) end function move.word_right(editor) -- skip some whitespace while true do if editor.cursor.pos == nil or editor.cursor.pos > utf8.len(editor.lines[editor.cursor.line].data) then break end if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%S') then break end I.right_arrow_without_scroll(editor) end while true do I.right_arrow_without_scroll(editor) if editor.cursor.pos == nil or editor.cursor.pos > utf8.len(editor.lines[editor.cursor.line].data) then break end if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%s') then break end end move.maybe_snap_cursor_to_bottom_of_screen(editor) end function move.maybe_snap_cursor_to_top_of_screen(editor) local _, y = move.to_coord(editor, editor.cursor) -- scan if y == nil then editor.screen_top = I.loc_hor(editor, editor.cursor, editor.left) -- scan assert(editor.screen_top) end end -- 1-2 scans function move.maybe_snap_cursor_to_bottom_of_screen(editor) local _, y = move.to_coord(editor, editor.cursor) -- scan -- Naively we'd just scroll up by editor height. However: -- 1. Some portion of it that doesn't divide in line height is useless local h = editor.line_height local height = math.floor((editor.bottom-editor.top)/h)*h if y == nil or y > height then -- 2. We want the cursor to still be fully visible. (Otherwise if the -- editor height is just 1px more than a multiple of line height, you -- might see just a single pixel of red peeking out from the bottom -- margin.) -- I believe 1 and 2 are distinct issues. Certainly I need to do both for -- the viewport to scroll as I type past the bottom. editor.screen_top = I.loc_up(editor, editor.cursor, height - h) -- scan assert(editor.screen_top) else -- no need to scroll return end end -- 1-2 scans ---- helpers for moving around the editor -- These simulate drawing on screen without actually doing so. -- -- These _do_ depend on the data structures the editor uses. But you should go -- a long way just by reimplementing them if you ever change the data structures. -- These are based on the output of rects.compute, which provides: -- - an array of rects {x,y, dx,dy}, one for each line -- - if it's a text line: -- - rects for each screen line in rect[].screen_line_rects, and -- - rects for each utf8 codepoint in rect[].screen_line_rects[].char_rects function move.to_loc(editor, mx,my) if my < editor.top then return t.deepcopy(editor.screen_top) end local x, y, maxy = mx - editor.left, my - editor.top, editor.bottom - editor.top local rect for line_index = editor.screen_top.line, #editor.lines do if maxy < editor.line_height then break end if line_index == editor.screen_top.line then rect = rects.compute(editor, editor.screen_top, maxy) else rect = rects.compute(editor, {line=line_index, pos=1}, maxy) end if y < rect.dy then local s = I.find_y(rect.screen_line_rects, y) local char_rect = I.find_xy(s.char_rects, x, y) assert(char_rect) return {line=line_index, pos=char_rect.pos} end y = y - rect.dy maxy = maxy - rect.dy if y <= 0 then break end end -- below all lines; return final rect on screen assert(rect, 'editor is too short to fit even one line') local s = rect.screen_line_rects local c = s[#s].char_rects return {line=rect.line_index, pos=c[#c].pos} end function move.to_coord(editor, loc) -- scans if Loc.lt(loc, editor.screen_top) then return 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 = rects.compute(editor, editor.screen_top) else rect = rects.compute(editor, {line=line_index, pos=1}) end if line_index == loc.line then for _,s in ipairs(rect.screen_line_rects) do for _,c in ipairs(s.char_rects) do if c.pos == loc.pos and c.show_cursor and (c.data or c.pos == utf8.len(line.data)+1) then return editor.left + c.x, y + c.y end end end assert(false, 'to_coord: invalid pos in text loc') end y = y + rect.dy if y > editor.bottom then break end end end -- 1 scan -- find the location at the start of a screen line dy pixels down from loc -- return bottommost screen line in file if we hit it function I.loc_down(editor, loc, dy) -- scans local y = 0 local prevloc = loc for line_index = loc.line, #editor.lines do local rect = rects.compute(editor, {line=line_index, pos=1}) for _,s in ipairs(rect.screen_line_rects) do if line_index > loc.line or s.pos+s.dpos > loc.pos then local currloc = {line=line_index, pos=s.pos} if y + s.dy > dy then return currloc end y = y + s.dy prevloc = currloc end end end return prevloc end -- 1 scan -- find the location at the start of a screen line dy pixels up from loc -- return topmost screen line in file if we hit it function I.loc_up(editor, loc, dy) -- scans dy = math.max(dy, 0) local y = 0 -- special handling for loc's line local rect = rects.compute(editor, {line=loc.line, pos=1}) local found = false for is = #rect.screen_line_rects,1,-1 do local s = rect.screen_line_rects[is] if not found and I.within(loc.pos, s.pos, s.pos+s.dpos) then if dy == 0 then return {line=loc.line, pos=s.pos} end found = true elseif found then if y + s.dy >= dy then return {line=loc.line, pos=s.pos} end y = y + s.dy else -- below loc's screen line; skip end end for line_index = loc.line-1,1,-1 do local line = editor.lines[line_index].data local rect = rects.compute(editor, {line=line_index, pos=1}) for is = #rect.screen_line_rects,1,-1 do local s = rect.screen_line_rects[is] if y + s.dy >= dy then return {line=line_index, pos=s.pos} end y = y + s.dy end end return I.top_loc(editor) end -- 1 scan function I.top_loc(editor) assert(#editor.lines > 0) return {line=1, pos=1} end -- find the location at x=x0 on the same screen line as loc function I.loc_hor(editor, loc, x0) -- scans line local rect = rects.compute(editor, {line=loc.line, pos=1}) x0 = x0 - editor.left assert(rect.screen_line_rects) for i,s in ipairs(rect.screen_line_rects) do if loc.pos >= s.pos and loc.pos < s.pos+s.dpos then local prevx = nil for _,c in ipairs(s.char_rects) do if (prevx == nil or x0 >= (prevx+c.x)/2) and x0 < c.x+c.dx/2 and c.show_cursor then return {line=loc.line, pos=c.pos} end prevx = c.x end return {line=loc.line, pos=s.pos+s.dpos-1} end end end -- 0-1 scans function I.find_xy(rects, x, y) if x < 0 then return rects[1] end for _, rect in ipairs(rects) do if I.within_rect(rect, x, y) then return rect end end return rects[#rects] end function I.find_x(rects, x) if x < 0 then return rects[1] end for _, rect in ipairs(rects) do if I.within(x, rect.x, rect.x+rect.dx) then return rect end end return rects[#rects] end function I.find_y(rects, y) if y < 0 then return rects[1] end for _, rect in ipairs(rects) do if I.within(y, rect.y, rect.y+rect.dy) then return rect end end return rects[#rects] end function I.within_rect(rect, x,y) return I.within(x, rect.x, rect.x+rect.dx) and I.within(y, rect.y, rect.y+rect.dy) end function I.within(a, lo, hi) return a >= lo and a < hi end return move