lines2.love/move.lua

654 lines
21 KiB
Lua

---- moving around the editor in terms of locations (loc)
--
-- locations within text lines look like this:
-- {mode='text', line=, pos=}
-- (pos counts in utf8 codepoints starting from 1)
--
-- locations within drawings look like this:
-- {mode='drawing', line=}
-- (drawing lines are atomic as far as the cursor is concerned)
--
-- 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
-- - down: loc, dy -> loc
-- find the location at the start of a screen line dy pixels down from loc
-- returns nil if dy is taller than the screen
-- returns bottom of file if we hit it
-- - up: loc, dy -> loc
-- find the location at the start of a screen line dy pixels up from loc
-- returns nil if dy is taller than the screen
-- returns top of file if we hit it
-- - hor: loc, x -> loc
-- find the location at x=x0 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:
-- - text is the bottleneck; my line drawings are clear and cheap no matter
-- how you go about things.
-- - 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 Text.up(editor)
local x, _ = edit.to_coord(editor, editor.cursor) -- scan
editor.cursor = edit.up(editor, editor.cursor, 1 --[[px]]) -- scan
assert(editor.cursor)
editor.cursor = edit.hor(editor, editor.cursor, x) -- 0-1 scan
assert(editor.cursor)
maybe_snap_cursor_to_top_of_screen(editor) -- 0-1 scan
end -- 2-4 scans
function Text.down(editor)
local x, _ = edit.to_coord(editor, editor.cursor) -- scan
local dy0 = edit.screen_line_height(editor, editor.cursor)
editor.cursor = edit.down(editor, editor.cursor, dy0) -- scan
assert(editor.cursor)
editor.cursor = edit.hor(editor, editor.cursor, x) -- 0-1 scan
assert(editor.cursor)
maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans
end -- 2-5 scans
function Text.left(editor)
Text.left_without_scroll(editor) -- 0-2 scans
maybe_snap_cursor_to_top_of_screen(editor) -- 0-1 scan
end -- 0-3 scans
function Text.left_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 = edit.up(editor, editor.cursor, 1 --[[px]]) -- scan
assert(editor.cursor)
editor.cursor = edit.hor(editor, editor.cursor, editor.right) -- 0-1 scan
assert(editor.cursor)
end
end -- 0-2 scans
-- without cheating
--? function Text.left_without_scroll(editor)
--? local x, _ = edit.to_coord(editor, editor.cursor) -- scan
--? if x <= editor.left then -- 0-2 scans
--? editor.cursor = edit.up(editor, editor.cursor, 1 --[[px]]) -- scan
--? editor.cursor = edit.hor(editor, editor.cursor, editor.right) -- 0-1 scan
--? else
--? editor.cursor = edit.hor(editor, editor.cursor, x-1) -- 0-1 scan
--? end
--? end -- 1-3 scans
function Text.right(editor)
Text.right_without_scroll(editor) -- 0-3 scans
maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans
end -- 0-5 scans
function Text.right_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 = edit.to_coord(editor, editor.cursor) -- scan
local dy = edit.screen_line_height(editor, editor.cursor)
local new_cursor = edit.down(editor, editor.cursor, dy) -- scan
local _, newy = edit.to_coord(editor, new_cursor) -- scan
if newy ~= y then
-- not at bottom of file
editor.cursor = new_cursor
end
end
end -- 0-3 scans
function Text.pageup(editor)
editor.screen_top = edit.up(editor, editor.screen_top, editor.bottom - editor.top - editor.line_height) -- scan
assert(editor.screen_top)
editor.cursor = deepcopy(editor.screen_top)
assert(editor.cursor)
end -- 1 scan
function Text.pagedown(editor)
editor.screen_top = edit.down(editor, editor.screen_top, editor.bottom - editor.top - editor.line_height) -- scan
assert(editor.screen_top)
editor.cursor = deepcopy(editor.screen_top)
assert(editor.cursor)
end -- 1 scan
function Text.start_of_line(editor)
if editor.cursor.mode == 'drawing' then return end
editor.cursor.pos = 1
maybe_snap_cursor_to_top_of_screen(editor) -- 0-1 scan
end -- 0-1 scan
function Text.end_of_line(editor)
if editor.cursor.mode == 'drawing' then return end
editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line].data) + 1
maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-1 scan
end -- 0-1 scan
function Text.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 Text.match(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%S') then
break
end
Text.left_without_scroll(editor)
end
-- skip some non-whitespace
while true do
Text.left_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 Text.match(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%s') then
break
end
end
maybe_snap_cursor_to_top_of_screen(editor)
end
function Text.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 Text.match(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%S') then
break
end
Text.right_without_scroll(editor)
end
while true do
Text.right_without_scroll(editor)
if editor.cursor.pos == nil or editor.cursor.pos > utf8.len(editor.lines[editor.cursor.line].data) then
break
end
if Text.match(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%s') then
break
end
end
maybe_snap_cursor_to_bottom_of_screen(editor)
end
function Text.match(s, pos, pat)
local start_offset = Text.offset(s, pos)
local end_offset = Text.offset(s, pos+1)
assert(end_offset > start_offset, ('end_offset %d not > start_offset %d'):format(end_offset, start_offset))
local curr = s:sub(start_offset, end_offset-1)
return curr:match(pat)
end
function maybe_snap_cursor_to_top_of_screen(editor)
if edit.lt(editor.cursor, editor.screen_top) then
editor.screen_top = edit.hor(editor, editor.cursor, editor.left) -- scan
assert(editor.screen_top)
end
end -- 0-1 scan
function maybe_snap_cursor_to_bottom_of_screen(editor)
local _, y = edit.to_coord(editor, editor.cursor) -- scan
local h = edit.screen_line_height(editor, editor.cursor)
if y == nil or y > editor.bottom - editor.top - h then
editor.screen_top = edit._up_whole_screen_lines(editor, editor.cursor, editor.bottom - editor.top - h) -- scan
assert(editor.screen_top)
else
-- no need to scroll
return
end
end -- 1-2 scans
---- various primitives that 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 edit.get_rect, 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 edit.to_loc(editor, mx,my)
if my < editor.top then
return deepcopy(editor.screen_top)
end
local x, y = mx - editor.left, my - editor.top
local rect
for line_index,line in array.each(editor.lines, editor.screen_top.line) do
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 line.mode == 'text' then
if y < rect.dy then
local s = find_y(rect.screen_line_rects, y)
local char_rect = find_x(s.char_rects, x)
assert(char_rect)
return {mode='text', line=line_index, pos=char_rect.pos}
else
y = y - rect.dy
if y <= 0 then
break
end
end
else
if y < rect.dy then
return {mode='drawing', line=line_index}
end
y = y - rect.dy
end
if y <= 0 then
break
end
end
-- below all lines; return final rect on screen
if rect.screen_line_rects then
local s = rect.screen_line_rects
local c = s[#s].char_rects
return {mode='text', line=rect.line_index, pos=c[#c].pos}
else
return {mode='drawing', line=rect.line_index}
end
end
function edit.to_coord(editor, loc) -- scans
if edit.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 = edit.get_rect(editor, editor.screen_top)
else
rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1})
end
if line_index == loc.line then
if rect.screen_line_rects then
-- text
for _,s in ipairs(rect.screen_line_rects) do
for _,c in ipairs(s.char_rects) do
if c.pos == loc.pos then
return editor.left + c.x, y + c.y
end
end
end
assert(false, 'edit.to_coord: invalid pos in text loc')
else
-- drawing
return editor.left, y + rect.y + Drawing_padding_top
end
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 nil if dy is more than screen height away
-- return bottommost screen line in file if we hit it
function edit.down(editor, loc, dy) -- scans
local y = 0
local prevloc = loc
for line_index, line in array.each(editor.lines, loc.line) do
local rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1})
if rect.screen_line_rects then
-- text
for _,s in ipairs(rect.screen_line_rects) do
if line_index > loc.line or loc.pos < s.pos+s.dpos then
local currloc = {mode='text', line=line_index, pos=s.pos}
if y + s.dy > dy then
return currloc
end
y = y + s.dy
prevloc = currloc
end
end
else
-- drawing
currloc = {mode='drawing', line=line_index}
if y + rect.dy > dy then
return currloc
end
y = y + rect.dy
prevloc = currloc
end
end
return prevloc
end -- 1 scan
-- find the location at the start of a screen line dy pixels up from loc
-- return nil if dy is more than screen height away
-- return topmost screen line in file if we hit it
function edit.up(editor, loc, dy) -- scans
local y = 0
-- special handling for loc's line
local rect = edit.get_rect(editor, {mode=loc.mode, line=loc.line, pos=1})
if rect.screen_line_rects then
-- text
local found = false
for is = #rect.screen_line_rects,1,-1 do
local s = rect.screen_line_rects[is]
if not found and within(loc.pos, s.pos, s.pos+s.dpos) then
found = true
elseif found then
if y + s.dy > dy then
return {mode='text', line=loc.line, pos=s.pos}
end
y = y + s.dy
else
-- below loc's screen line; skip
end
end
else
-- drawing; skip
end
for line_index = loc.line-1,1,-1 do
local line = editor.lines[line_index]
local rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1})
if rect.screen_line_rects then
-- text
for is = #rect.screen_line_rects,1,-1 do
local s = rect.screen_line_rects[is]
if y + s.dy > dy then
return {mode='text', line=line_index, pos=s.pos}
end
y = y + s.dy
end
else
-- drawing
if y + rect.dy > dy then
return {mode='drawing', line=line_index}
end
y = y + rect.dy
end
end
return edit.top(editor)
end -- 1 scan
-- find the location at the start of a screen line up to dy pixels up from loc, but count only whole screen lines.
-- So the result will be at or below dy pixels above loc.
function edit._up_whole_screen_lines(editor, loc, dy) -- scans
local prevloc = loc -- bug: not at start of screen line
local y = 0
-- special handling for loc's line
local rect = edit.get_rect(editor, {mode=loc.mode, line=loc.line, pos=1})
if rect.screen_line_rects then
-- text
local found = false
for is = #rect.screen_line_rects,1,-1 do
local s = rect.screen_line_rects[is]
if not found and within(loc.pos, s.pos, s.pos+s.dpos) then
found = true
elseif found then
local currloc = {mode='text', line=loc.line, pos=s.pos}
if y + s.dy > dy then
return prevloc
end
y = y + s.dy
prevloc = currloc
else
-- below loc's screen line; skip
end
end
else
-- drawing; skip
end
for line_index = loc.line-1,1,-1 do
local line = editor.lines[line_index]
local rect = edit.get_rect(editor, {mode=line.mode, line=line_index, pos=1})
if rect.screen_line_rects then
-- text
for is = #rect.screen_line_rects,1,-1 do
local s = rect.screen_line_rects[is]
local currloc = {mode='text', line=line_index, pos=s.pos}
if y + s.dy > dy then
return prevloc
end
y = y + s.dy
prevloc = currloc
end
else
-- drawing
currloc = {mode='drawing', line=line_index}
if y + rect.dy > dy then
return prevloc
end
y = y + rect.dy
prevloc = currloc
end
end
return edit.top(editor)
end -- 1 scan
function edit.top(editor)
assert(#editor.lines > 0)
if editor.lines[1].mode == 'text' then
return {mode='text', line=1, pos=1}
else
return {mode='drawing', line=1}
end
end
-- find the location at x=x0 on the same screen line as loc
function edit.hor(editor, loc, x0) -- scans line
if loc.mode == 'drawing' then
assert(editor.lines[loc.line].mode == 'drawing')
return deepcopy(loc)
end
local rect = edit.get_rect(editor, {mode=loc.mode, line=loc.line, pos=1})
x0 = x0 - editor.left
assert(rect.screen_line_rects)
for i,sc in ipairs(rect.screen_line_rects) do
if loc.pos >= sc.pos and loc.pos < sc.pos+sc.dpos then
local prevx = nil
for _,c in ipairs(sc.char_rects) do
if (prevx == nil or x0 >= (prevx+c.x)/2) and x0 < c.x+c.dx/2 then
return {mode='text', line=loc.line, pos=c.pos}
end
prevx = c.x
end
return {mode='text', line=loc.line, pos=sc.pos+sc.dpos-1}
end
end
end -- 0-1 scans
-- helper to check if space character at pos, x is final possible candidate
-- for line wrapping before x = editor.right
-- x lies between editor.left and editor.right
function Text.line_wrap_at_word_boundary(editor, x, line, pos)
if x - editor.left > 0.8 * (editor.right - editor.left) then
-- word boundary more than 80% of the way across.
-- check if there's any more word boundaries before x gets to editor.right
for pos, char in utf8chars(line, pos+1) do
if x > editor.right then
return true
end
local w = editor.font:getWidth(char) -- width of char
if char:match('%s') then
if x+w < editor.right then
break
end
end
x = x+w
end
end
end
function edit.screen_line_height(editor, loc)
if loc.mode == 'text' then
return editor.line_height
else
local line = editor.lines[loc.line]
assert(line.mode == 'drawing')
return Drawing.height(editor, line)
end
end
function Drawing.height(editor, line)
return Drawing_padding_height + Drawing.pixels(line.h, editor.width)
end
function edit.eq(a, b)
return a.mode == b.mode and a.line == b.line and --[[just for text mode]] a.pos == b.pos
end
function edit.lt(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
if a.mode == 'drawing' then
return false -- no further comparisons within drawings
end
return a.pos < b.pos
end
function edit.le(a, b)
if a.line < b.line then
return true
end
if a.line > b.line then
return false
end
if a.mode == 'drawing' then
return true -- no further comparisons within drawings
end
return a.pos <= b.pos
end
function edit.get_rect(editor, loc)
if loc.mode == 'text' then
return Text.get_rect(editor, loc)
else
return {x=0, y=0, dx=editor.width, dy=edit.screen_line_height(editor, loc)}
end
end
-- generate rects for each screen line in it and the range [pos,pos+dpos-1] associated with each
-- within each screen line generate rects for each char (utf8 codepoint) and the pos associated with each.
function Text.get_rect(editor, loc)
local line = editor.lines[loc.line]
assert(line.mode == 'text')
local screen_lines = {}
local curr_screen_line = {}
local spos = 1
local x, y = 0, 0
for pos,char in utf8chars(line.data, loc.pos) do
local w = editor.font:getWidth(char)
if char:match('%s') then
if Text.line_wrap_at_word_boundary(editor, editor.left + x, line.data, pos) then
table.insert(curr_screen_line,
{x=x, y=y, dx=editor.width-x, dy=editor.line_height, pos=pos, data=char}) -- char+filler
table.insert(screen_lines,
{x=0, y=y, dx=editor.width, dy=editor.line_height,
pos=spos, dpos=pos-spos+1, char_rects=curr_screen_line})
curr_screen_line = {}
spos = pos+1
x = 0
y = y + editor.line_height
if y > editor.bottom - editor.top then
return {x=0, y=0, dx=editor.width, dy=y, screen_line_rects=screen_lines}
end
else
table.insert(curr_screen_line,
{x=x, y=y, dx=w, dy=editor.line_height, pos=pos, data=char})
x = x + w
end
else
if x+w > editor.width then
assert(pos > 1)
table.insert(curr_screen_line,
{x=x, y=y, dx=editor.width-x, dy=editor.line_height, pos=pos-1}) -- filler
table.insert(screen_lines,
{x=0, y=y, dx=editor.width, dy=editor.line_height,
pos=spos, dpos=(pos-1)-spos+1, char_rects=curr_screen_line})
curr_screen_line = {}
spos = pos
x = 0
y = y + editor.line_height
if y > editor.bottom - editor.top then
return {x=0, y=0, dx=editor.width, dy=y, screen_line_rects=screen_lines}
end
else
-- nothing
end
table.insert(curr_screen_line,
{x=x, y=y, dx=w, dy=editor.line_height, pos=pos, data=char})
x = x + w
end
end
table.insert(curr_screen_line,
{x=x, y=y, dx=editor.width-x, dy=editor.line_height, pos=utf8.len(line.data)+1}) -- filler
table.insert(screen_lines,
{x=0, y=y, dx=editor.width, dy=editor.line_height,
pos=spos, dpos=utf8.len(line.data)+1-spos+1, char_rects=curr_screen_line})
y = y + editor.line_height
return {x=0, y=0, dx=editor.width, dy=y, line_index=loc.line, screen_line_rects=screen_lines}
end
function find_x(rects, x)
if x < 0 then return rects[1] end
for _, rect in ipairs(rects) do
if within(x, rect.x, rect.x+rect.dx) then
return rect
end
end
return rects[#rects]
end
function find_y(rects, y)
if y < 0 then return rects[1] end
for _, rect in ipairs(rects) do
if within(y, rect.y, rect.y+rect.dy) then
return rect
end
end
return rects[#rects]
end
function within_rect(rect, x,y)
return within(x, rect.x, rect.x+rect.dx)
and within(y, rect.y, rect.y+rect.dy)
end
function within(a, lo, hi)
return a >= lo and a < hi
end
-- create a new iterator for s which provides the index and UTF-8 bytes corresponding to each codepoint
function utf8chars(s, startpos)
local next_pos = startpos or 1 -- in code points
local next_offset = utf8.offset(s, next_pos) -- in bytes
return function()
assert(next_offset) -- never call the iterator after it returns nil
local curr_pos = next_pos
next_pos = next_pos+1
local curr_offset = next_offset
next_offset = utf8.offset(s, 2, next_offset)
if next_offset == nil then return end
local curr_char = s:sub(curr_offset, next_offset-1)
return curr_pos, curr_char
end
end