Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
at main 421 lines 14 kB view raw
1local utf8 = require 'utf8' 2 3local array = require 'array' 4local my_utf8 = require 'my_utf8' 5local t = require 'utils' 6local rects = require 'rects' 7local Loc = require 'loc' 8 9local move = {} 10local I = {} 11move.internal = I 12 13---- moving around the editor in terms of locations (loc) 14-- 15-- locations within text lines look like this: 16-- {line=, pos=} 17-- (pos counts in utf8 codepoints starting from 1) 18-- 19-- all movements are built primarily using the following primitives, defined 20-- further down: 21-- - to_loc: (x, y) -> loc 22-- identify the location at pixel coordinates (x,y) on screen 23-- returns nil if (x,y) is not on screen 24-- - to_coord: loc -> x, y 25-- identify the top-left coordinate on screen of location loc 26-- returns nil if loc is not on screen 27-- - loc_down: loc, dy -> loc 28-- find the location at the start of a screen line dy pixels down from loc 29-- returns bottom of file if we hit it 30-- - loc_up: loc, dy -> loc 31-- find the location at the start of a screen line dy pixels up from loc 32-- returns top of file if we hit it 33-- - loc_hor: loc, x -> loc 34-- find the location at x on the same screen line as loc 35-- 36-- I have tried to make these definitions as clear as possible while being fast enough. 37-- My mental model for trading off performance for clarity: 38-- - any computation limited to the number of characters a screen can show 39-- will be an order of magnitude faster than it needs to be to draw 30 40-- frames per second. 41-- So I don't mind doing up to 5 scans per interactive operation if that 42-- makes the code clearer. 43-- - no caching across frames; it makes the code less clear and has also 44-- caused bugs. 45-- - short-lived memory allocations that live within a single frame are cheap 46-- 47-- This API was non-trivial to arrive at. It seems promising for any 48-- pixel-based editor using proportional fonts. It seems independent of the 49-- data structures the editor uses, whether arrays as here or ropes or gap 50-- buffers, though I cheat a bit and use knowledge of the data structures 51-- where it saves me a scan or two. 52 53function move.up_arrow(editor) 54 local x, _ = move.to_coord(editor, editor.cursor) -- scan 55 editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan 56 assert(editor.cursor) 57 editor.cursor = I.loc_hor(editor, editor.cursor, x) -- 0-1 scan 58 assert(editor.cursor) 59 move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans 60end -- 3-5 scans 61 62function move.down_arrow(editor) 63 local x, _ = move.to_coord(editor, editor.cursor) -- scan 64 local dy0 = rects.height_of_screen_line(editor, editor.cursor) 65 editor.cursor = I.loc_down(editor, editor.cursor, dy0) -- scan 66 assert(editor.cursor) 67 editor.cursor = I.loc_hor(editor, editor.cursor, x) -- 0-1 scan 68 assert(editor.cursor) 69 move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans 70end -- 2-5 scans 71 72function move.left_arrow(editor) 73 I.left_arrow_without_scroll(editor) -- 0-2 scans 74 move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans 75end -- 1-4 scans 76 77function I.left_arrow_without_scroll(editor) 78 if editor.cursor.pos and editor.cursor.pos > 1 then 79 editor.cursor.pos = editor.cursor.pos-1 80 elseif editor.cursor.line > 1 then 81 editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan 82 assert(editor.cursor) 83 editor.cursor = I.loc_hor(editor, editor.cursor, editor.right) -- 0-1 scan 84 assert(editor.cursor) 85 end 86end -- 0-2 scans 87 88-- without cheating 89--? function I.left_arrow_without_scroll(editor) 90--? local x, _ = move.to_coord(editor, editor.cursor) -- scan 91--? if x <= editor.left then -- 0-2 scans 92--? editor.cursor = I.loc_up(editor, editor.cursor, 1 --[[px]]) -- scan 93--? editor.cursor = I.loc_hor(editor, editor.cursor, editor.right) -- 0-1 scan 94--? else 95--? editor.cursor = I.loc_hor(editor, editor.cursor, x-1) -- 0-1 scan 96--? end 97--? end -- 1-3 scans 98 99function move.right_arrow(editor) 100 I.right_arrow_without_scroll(editor) -- 0-2 scans 101 move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-2 scans 102end -- 0-4 scans 103 104function I.right_arrow_without_scroll(editor) 105 if editor.cursor.pos and editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then 106 editor.cursor.pos = editor.cursor.pos+1 107 else 108 local _, y = move.to_coord(editor, editor.cursor) -- scan 109 local dy = rects.height_of_screen_line(editor, editor.cursor) 110 local new_cursor = I.loc_down(editor, editor.cursor, dy) -- scan 111 if Loc.lt(editor.cursor, new_cursor) then -- there's further down to go 112 editor.cursor = new_cursor 113 end 114 end 115end -- 0-2 scans 116 117function move.pageup(editor) 118 -- Naively we'd just scroll up by editor height. However: 119 -- 1. Some portion of it that doesn't divide in line height is useless 120 local h = editor.line_height 121 local height = math.floor((editor.bottom-editor.top)/h)*h 122 -- 2. We want to display one line of overlap between the current page and previous. 123 -- I believe 1 and 2 are distinct issues. Certainly I need to do both for 124 -- pageup and pagedown to be precisely symmetrical at all combinations of 125 -- screen heights and line heights. 126 editor.screen_top = I.loc_up(editor, editor.screen_top, height - h) -- scan 127 assert(editor.screen_top) 128 editor.cursor = t.deepcopy(editor.screen_top) 129 assert(editor.cursor) 130end -- 1 scan 131 132function move.pagedown(editor) 133 editor.screen_top = I.loc_down(editor, editor.screen_top, editor.bottom - editor.top - editor.line_height) -- scan 134 assert(editor.screen_top) 135 editor.cursor = t.deepcopy(editor.screen_top) 136 assert(editor.cursor) 137end -- 1 scan 138 139function move.start_of_line(editor) 140 editor.cursor.pos = 1 141 move.maybe_snap_cursor_to_top_of_screen(editor) -- 1-2 scans 142end -- 1-2 scan 143 144function move.end_of_line(editor) 145 editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line].data) + 1 146 move.maybe_snap_cursor_to_bottom_of_screen(editor) -- 0-1 scan 147end -- 0-1 scan 148 149function move.word_left(editor) 150 -- skip some whitespace 151 while true do 152 if editor.cursor.pos == nil or editor.cursor.pos == 1 then 153 break -- line boundary is always whitespace 154 end 155 if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%S') then 156 break 157 end 158 I.left_arrow_without_scroll(editor) 159 end 160 -- skip some non-whitespace 161 while true do 162 I.left_arrow_without_scroll(editor) 163 if editor.cursor.pos == nil or editor.cursor.pos == 1 then 164 break 165 end 166 assert(editor.cursor.pos > 1, 'bumped up against start of line') 167 if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos-1, '%s') then 168 break 169 end 170 end 171 move.maybe_snap_cursor_to_top_of_screen(editor) 172end 173 174function move.word_right(editor) 175 -- skip some whitespace 176 while true do 177 if editor.cursor.pos == nil or editor.cursor.pos > utf8.len(editor.lines[editor.cursor.line].data) then 178 break 179 end 180 if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%S') then 181 break 182 end 183 I.right_arrow_without_scroll(editor) 184 end 185 while true do 186 I.right_arrow_without_scroll(editor) 187 if editor.cursor.pos == nil or editor.cursor.pos > utf8.len(editor.lines[editor.cursor.line].data) then 188 break 189 end 190 if my_utf8.match_at(editor.lines[editor.cursor.line].data, editor.cursor.pos, '%s') then 191 break 192 end 193 end 194 move.maybe_snap_cursor_to_bottom_of_screen(editor) 195end 196 197function move.maybe_snap_cursor_to_top_of_screen(editor) 198 local _, y = move.to_coord(editor, editor.cursor) -- scan 199 if y == nil then 200 editor.screen_top = I.loc_hor(editor, editor.cursor, editor.left) -- scan 201 assert(editor.screen_top) 202 end 203end -- 1-2 scans 204 205function move.maybe_snap_cursor_to_bottom_of_screen(editor) 206 local _, y = move.to_coord(editor, editor.cursor) -- scan 207 -- Naively we'd just scroll up by editor height. However: 208 -- 1. Some portion of it that doesn't divide in line height is useless 209 local h = editor.line_height 210 local height = math.floor((editor.bottom-editor.top)/h)*h 211 if y == nil or y > height then 212 -- 2. We want the cursor to still be fully visible. (Otherwise if the 213 -- editor height is just 1px more than a multiple of line height, you 214 -- might see just a single pixel of red peeking out from the bottom 215 -- margin.) 216 -- I believe 1 and 2 are distinct issues. Certainly I need to do both for 217 -- the viewport to scroll as I type past the bottom. 218 editor.screen_top = I.loc_up(editor, editor.cursor, height - h) -- scan 219 assert(editor.screen_top) 220 else 221 -- no need to scroll 222 return 223 end 224end -- 1-2 scans 225 226---- helpers for moving around the editor 227-- These simulate drawing on screen without actually doing so. 228-- 229-- These _do_ depend on the data structures the editor uses. But you should go 230-- a long way just by reimplementing them if you ever change the data structures. 231 232-- These are based on the output of rects.compute, which provides: 233-- - an array of rects {x,y, dx,dy}, one for each line 234-- - if it's a text line: 235-- - rects for each screen line in rect[].screen_line_rects, and 236-- - rects for each utf8 codepoint in rect[].screen_line_rects[].char_rects 237 238function move.to_loc(editor, mx,my) 239 if my < editor.top then 240 return t.deepcopy(editor.screen_top) 241 end 242 local x, y, maxy = mx - editor.left, my - editor.top, editor.bottom - editor.top 243 local rect 244 for line_index = editor.screen_top.line, #editor.lines do 245 if maxy < editor.line_height then 246 break 247 end 248 if line_index == editor.screen_top.line then 249 rect = rects.compute(editor, editor.screen_top, maxy) 250 else 251 rect = rects.compute(editor, {line=line_index, pos=1}, maxy) 252 end 253 if y < rect.dy then 254 local s = I.find_y(rect.screen_line_rects, y) 255 local char_rect = I.find_xy(s.char_rects, x, y) 256 assert(char_rect) 257 return {line=line_index, pos=char_rect.pos} 258 end 259 y = y - rect.dy 260 maxy = maxy - rect.dy 261 if y <= 0 then 262 break 263 end 264 end 265 -- below all lines; return final rect on screen 266 assert(rect, 'editor is too short to fit even one line') 267 local s = rect.screen_line_rects 268 local c = s[#s].char_rects 269 return {line=rect.line_index, pos=c[#c].pos} 270end 271 272function move.to_coord(editor, loc) -- scans 273 if Loc.lt(loc, editor.screen_top) then return end 274 local y = editor.top 275 for line_index, line in array.each(editor.lines, editor.screen_top.line) do 276 local rect 277 if line_index == editor.screen_top.line then 278 rect = rects.compute(editor, editor.screen_top) 279 else 280 rect = rects.compute(editor, {line=line_index, pos=1}) 281 end 282 if line_index == loc.line then 283 for _,s in ipairs(rect.screen_line_rects) do 284 for _,c in ipairs(s.char_rects) do 285 if c.pos == loc.pos and c.show_cursor and (c.data or c.pos == utf8.len(line.data)+1) then 286 return editor.left + c.x, y + c.y 287 end 288 end 289 end 290 assert(false, 'to_coord: invalid pos in text loc') 291 end 292 y = y + rect.dy 293 if y > editor.bottom then 294 break 295 end 296 end 297end -- 1 scan 298 299-- find the location at the start of a screen line dy pixels down from loc 300-- return bottommost screen line in file if we hit it 301function I.loc_down(editor, loc, dy) -- scans 302 local y = 0 303 local prevloc = loc 304 for line_index = loc.line, #editor.lines do 305 local rect = rects.compute(editor, {line=line_index, pos=1}) 306 for _,s in ipairs(rect.screen_line_rects) do 307 if line_index > loc.line or s.pos+s.dpos > loc.pos then 308 local currloc = {line=line_index, pos=s.pos} 309 if y + s.dy > dy then 310 return currloc 311 end 312 y = y + s.dy 313 prevloc = currloc 314 end 315 end 316 end 317 return prevloc 318end -- 1 scan 319 320-- find the location at the start of a screen line dy pixels up from loc 321-- return topmost screen line in file if we hit it 322function I.loc_up(editor, loc, dy) -- scans 323 dy = math.max(dy, 0) 324 local y = 0 325 -- special handling for loc's line 326 local rect = rects.compute(editor, {line=loc.line, pos=1}) 327 local found = false 328 for is = #rect.screen_line_rects,1,-1 do 329 local s = rect.screen_line_rects[is] 330 if not found and I.within(loc.pos, s.pos, s.pos+s.dpos) then 331 if dy == 0 then 332 return {line=loc.line, pos=s.pos} 333 end 334 found = true 335 elseif found then 336 if y + s.dy >= dy then 337 return {line=loc.line, pos=s.pos} 338 end 339 y = y + s.dy 340 else 341 -- below loc's screen line; skip 342 end 343 end 344 for line_index = loc.line-1,1,-1 do 345 local line = editor.lines[line_index].data 346 local rect = rects.compute(editor, {line=line_index, pos=1}) 347 for is = #rect.screen_line_rects,1,-1 do 348 local s = rect.screen_line_rects[is] 349 if y + s.dy >= dy then 350 return {line=line_index, pos=s.pos} 351 end 352 y = y + s.dy 353 end 354 end 355 return I.top_loc(editor) 356end -- 1 scan 357 358function I.top_loc(editor) 359 assert(#editor.lines > 0) 360 return {line=1, pos=1} 361end 362 363-- find the location at x=x0 on the same screen line as loc 364function I.loc_hor(editor, loc, x0) -- scans line 365 local rect = rects.compute(editor, {line=loc.line, pos=1}) 366 x0 = x0 - editor.left 367 assert(rect.screen_line_rects) 368 for i,s in ipairs(rect.screen_line_rects) do 369 if loc.pos >= s.pos and loc.pos < s.pos+s.dpos then 370 local prevx = nil 371 for _,c in ipairs(s.char_rects) do 372 if (prevx == nil or x0 >= (prevx+c.x)/2) and x0 < c.x+c.dx/2 and c.show_cursor then 373 return {line=loc.line, pos=c.pos} 374 end 375 prevx = c.x 376 end 377 return {line=loc.line, pos=s.pos+s.dpos-1} 378 end 379 end 380end -- 0-1 scans 381 382function I.find_xy(rects, x, y) 383 if x < 0 then return rects[1] end 384 for _, rect in ipairs(rects) do 385 if I.within_rect(rect, x, y) then 386 return rect 387 end 388 end 389 return rects[#rects] 390end 391 392function I.find_x(rects, x) 393 if x < 0 then return rects[1] end 394 for _, rect in ipairs(rects) do 395 if I.within(x, rect.x, rect.x+rect.dx) then 396 return rect 397 end 398 end 399 return rects[#rects] 400end 401 402function I.find_y(rects, y) 403 if y < 0 then return rects[1] end 404 for _, rect in ipairs(rects) do 405 if I.within(y, rect.y, rect.y+rect.dy) then 406 return rect 407 end 408 end 409 return rects[#rects] 410end 411 412function I.within_rect(rect, x,y) 413 return I.within(x, rect.x, rect.x+rect.dx) 414 and I.within(y, rect.y, rect.y+rect.dy) 415end 416 417function I.within(a, lo, hi) 418 return a >= lo and a < hi 419end 420 421return move