Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
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