1 -- text editor, particularly text drawing, horizontal wrap, vertical scrolling
4 -- draw a line starting from startpos to screen at y between State.left and State.right
5 -- return y for the next line
6 function Text
.draw(State
, line_index
, y
, startpos
)
7 --? print('text.draw', line_index, y)
8 local line
= State
.lines
[line_index
]
9 local line_cache
= State
.line_cache
[line_index
]
10 line_cache
.startpos
= startpos
12 Text
.populate_screen_line_starting_pos(State
, line_index
)
13 assert(#line_cache
.screen_line_starting_pos
>= 1, 'line cache missing screen line info')
14 for i
=1,#line_cache
.screen_line_starting_pos
do
15 local pos
= line_cache
.screen_line_starting_pos
[i
]
16 if pos
< startpos
then
19 local screen_line
= Text
.screen_line(line
, line_cache
, i
)
20 --? print('text.draw:', screen_line, 'at', line_index,pos, 'after', x,y)
21 local frag_len
= utf8
.len(screen_line
)
22 -- render any highlights
23 if State
.selection1
.line
then
24 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
25 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
27 if line_index
== State
.cursor1
.line
then
28 -- render search highlight or cursor
29 if State
.search_term
then
30 local data
= State
.lines
[State
.cursor1
.line
].data
31 local cursor_offset
= Text
.offset(data
, State
.cursor1
.pos
)
32 if data
:sub(cursor_offset
, cursor_offset
+#State
.search_term
-1) == State
.search_term
then
33 local save_selection
= State
.selection1
34 State
.selection1
= {line
=line_index
, pos
=State
.cursor1
.pos
+utf8
.len(State
.search_term
)}
35 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
36 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
37 State
.selection1
= save_selection
40 if pos
<= State
.cursor1
.pos
and pos
+ frag_len
> State
.cursor1
.pos
then
41 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
42 elseif pos
+ frag_len
== State
.cursor1
.pos
then
43 -- Show cursor at end of line.
44 -- This place also catches end of wrapping screen lines. That doesn't seem worth distinguishing.
45 -- It seems useful to see a cursor whether your eye is on the left or right margin.
46 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
52 App
.screen
.print(screen_line
, State
.left
,y
)
53 y
= y
+ State
.line_height
54 if y
>= App
.screen
.height
then
62 function Text
.screen_line(line
, line_cache
, i
)
63 local pos
= line_cache
.screen_line_starting_pos
[i
]
64 local offset
= Text
.offset(line
.data
, pos
)
65 if i
>= #line_cache
.screen_line_starting_pos
then
66 return line
.data
:sub(offset
)
68 local endpos
= line_cache
.screen_line_starting_pos
[i
+1]-1
69 local end_offset
= Text
.offset(line
.data
, endpos
)
70 return line
.data
:sub(offset
, end_offset
)
73 function Text
.draw_cursor(State
, x
, y
)
75 if math
.floor(Cursor_time
*2)%2 == 0 then
76 App
.color(Cursor_color
)
77 love
.graphics
.rectangle('fill', x
,y
, 3,State
.line_height
)
80 State
.cursor_y
= y
+State
.line_height
83 function Text
.populate_screen_line_starting_pos(State
, line_index
)
84 local line
= State
.lines
[line_index
]
85 local line_cache
= State
.line_cache
[line_index
]
86 if line_cache
.screen_line_starting_pos
then
89 line_cache
.screen_line_starting_pos
= {1}
92 -- try to wrap at word boundaries
93 for frag
in line
.data
:gmatch('%S*%s*') do
94 local frag_width
= State
.font
:getWidth(frag
)
95 --? print('-- frag:', frag, pos, x, frag_width, State.width)
96 while x
+ frag_width
> State
.width
do
97 --? print('frag:', frag, pos, x, frag_width, State.width)
98 if x
< 0.8 * State
.width
then
99 -- long word; chop it at some letter
100 -- We're not going to reimplement TeX here.
101 local bpos
= Text
.nearest_pos_less_than(State
.font
, frag
, State
.width
- x
)
102 if x
== 0 and bpos
== 0 then
103 assert(false, ("Infinite loop while line-wrapping. Editor is %dpx wide; window is %dpx wide"):format(State
.width
, App
.screen
.width
))
106 local boffset
= Text
.offset(frag
, bpos
+1) -- byte _after_ bpos
107 frag
= string.sub(frag
, boffset
)
109 --? print('after chop:', frag)
111 frag_width
= State
.font
:getWidth(frag
)
113 --? print('screen line:', pos)
114 table.insert(line_cache
.screen_line_starting_pos
, pos
)
115 x
= 0 -- new screen line
118 pos
= pos
+ utf8
.len(frag
)
122 function Text
.text_input(State
, t
)
123 if App
.mouse_down(1) then return end
124 if App
.any_modifier_down() then
125 if App
.key_down(t
) then
126 -- The modifiers didn't change the key. Handle it in keychord_pressed.
129 -- Key mutated by the keyboard layout. Continue below.
132 local before
= snapshot(State
, State
.cursor1
.line
)
133 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
134 Text
.insert_at_cursor(State
, t
)
135 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
136 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
137 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
139 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
142 function Text
.insert_at_cursor(State
, t
)
143 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
144 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_offset
-1)..t
..string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_offset
)
145 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
146 State
.cursor1
.pos
= State
.cursor1
.pos
+1
149 -- Don't handle any keys here that would trigger text_input above.
150 function Text
.keychord_press(State
, chord
)
151 --? print('chord', chord, State.selection1.line, State.selection1.pos)
152 --== shortcuts that mutate text
153 if chord
== 'return' then
154 local before_line
= State
.cursor1
.line
155 local before
= snapshot(State
, before_line
)
156 Text
.insert_return(State
)
157 State
.selection1
= {}
158 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
159 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
162 record_undo_event(State
, {before
=before
, after
=snapshot(State
, before_line
, State
.cursor1
.line
)})
163 elseif chord
== 'tab' then
164 local before
= snapshot(State
, State
.cursor1
.line
)
165 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
166 Text
.insert_at_cursor(State
, '\t')
167 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
168 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
169 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
170 --? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
173 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
174 elseif chord
== 'backspace' then
175 if State
.selection1
.line
then
176 Text
.delete_selection(State
, State
.left
, State
.right
)
181 if State
.cursor1
.pos
> 1 then
182 before
= snapshot(State
, State
.cursor1
.line
)
183 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1)
184 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
187 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)..string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_end
)
189 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
191 State
.cursor1
.pos
= State
.cursor1
.pos
-1
193 elseif State
.cursor1
.line
> 1 then
194 before
= snapshot(State
, State
.cursor1
.line
-1, State
.cursor1
.line
)
196 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
-1].data
)+1
197 State
.lines
[State
.cursor1
.line
-1].data
= State
.lines
[State
.cursor1
.line
-1].data
..State
.lines
[State
.cursor1
.line
].data
198 table.remove(State
.lines
, State
.cursor1
.line
)
199 table.remove(State
.line_cache
, State
.cursor1
.line
)
200 State
.cursor1
.line
= State
.cursor1
.line
-1
202 if State
.screen_top1
.line
> #State
.lines
then
203 Text
.populate_screen_line_starting_pos(State
, #State
.lines
)
204 local line_cache
= State
.line_cache
[#State
.line_cache
]
205 State
.screen_top1
= {line
=#State
.lines
, pos
=line_cache
.screen_line_starting_pos
[#line_cache
.screen_line_starting_pos
]}
206 elseif Text
.lt1(State
.cursor1
, State
.screen_top1
) then
207 State
.screen_top1
= {
208 line
=State
.cursor1
.line
,
209 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
211 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
213 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
214 assert(Text
.le1(State
.screen_top1
, State
.cursor1
), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(State
.screen_top1
.line
, State
.screen_top1
.pos
, State
.cursor1
.line
, State
.cursor1
.pos
))
216 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
217 elseif chord
== 'delete' then
218 if State
.selection1
.line
then
219 Text
.delete_selection(State
, State
.left
, State
.right
)
224 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
225 before
= snapshot(State
, State
.cursor1
.line
)
227 before
= snapshot(State
, State
.cursor1
.line
, State
.cursor1
.line
+1)
229 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
230 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
231 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
+1)
234 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)..string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_end
)
236 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
238 -- no change to State.cursor1.pos
240 elseif State
.cursor1
.line
< #State
.lines
then
242 State
.lines
[State
.cursor1
.line
].data
= State
.lines
[State
.cursor1
.line
].data
..State
.lines
[State
.cursor1
.line
+1].data
243 table.remove(State
.lines
, State
.cursor1
.line
+1)
244 table.remove(State
.line_cache
, State
.cursor1
.line
+1)
246 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
248 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
249 --== shortcuts that move the cursor
250 elseif chord
== 'left' then
252 State
.selection1
= {}
253 elseif chord
== 'right' then
255 State
.selection1
= {}
256 elseif chord
== 'S-left' then
257 if State
.selection1
.line
== nil then
258 State
.selection1
= deepcopy(State
.cursor1
)
261 elseif chord
== 'S-right' then
262 if State
.selection1
.line
== nil then
263 State
.selection1
= deepcopy(State
.cursor1
)
266 -- C- hotkeys reserved for drawings, so we'll use M-
267 elseif chord
== 'M-left' then
268 Text
.word_left(State
)
269 State
.selection1
= {}
270 elseif chord
== 'M-right' then
271 Text
.word_right(State
)
272 State
.selection1
= {}
273 elseif chord
== 'M-S-left' then
274 if State
.selection1
.line
== nil then
275 State
.selection1
= deepcopy(State
.cursor1
)
277 Text
.word_left(State
)
278 elseif chord
== 'M-S-right' then
279 if State
.selection1
.line
== nil then
280 State
.selection1
= deepcopy(State
.cursor1
)
282 Text
.word_right(State
)
283 elseif chord
== 'home' then
284 Text
.start_of_line(State
)
285 State
.selection1
= {}
286 elseif chord
== 'end' then
287 Text
.end_of_line(State
)
288 State
.selection1
= {}
289 elseif chord
== 'S-home' then
290 if State
.selection1
.line
== nil then
291 State
.selection1
= deepcopy(State
.cursor1
)
293 Text
.start_of_line(State
)
294 elseif chord
== 'S-end' then
295 if State
.selection1
.line
== nil then
296 State
.selection1
= deepcopy(State
.cursor1
)
298 Text
.end_of_line(State
)
299 elseif chord
== 'up' then
301 State
.selection1
= {}
302 elseif chord
== 'down' then
304 State
.selection1
= {}
305 elseif chord
== 'S-up' then
306 if State
.selection1
.line
== nil then
307 State
.selection1
= deepcopy(State
.cursor1
)
310 elseif chord
== 'S-down' then
311 if State
.selection1
.line
== nil then
312 State
.selection1
= deepcopy(State
.cursor1
)
315 elseif chord
== 'pageup' then
317 State
.selection1
= {}
318 elseif chord
== 'pagedown' then
320 State
.selection1
= {}
321 elseif chord
== 'S-pageup' then
322 if State
.selection1
.line
== nil then
323 State
.selection1
= deepcopy(State
.cursor1
)
326 elseif chord
== 'S-pagedown' then
327 if State
.selection1
.line
== nil then
328 State
.selection1
= deepcopy(State
.cursor1
)
334 function Text
.insert_return(State
)
335 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
336 table.insert(State
.lines
, State
.cursor1
.line
+1, {data
=string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_offset
)})
337 table.insert(State
.line_cache
, State
.cursor1
.line
+1, {})
338 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_offset
-1)
339 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
340 State
.cursor1
= {line
=State
.cursor1
.line
+1, pos
=1}
343 function Text
.pageup(State
)
344 State
.screen_top1
= Text
.previous_screen_top1(State
)
345 State
.cursor1
= deepcopy(State
.screen_top1
)
346 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
347 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
350 -- return the top y coordinate of a given line_index,
351 -- or nil if no part of it is on screen
352 function Text
.starty(State
, line_index
)
353 -- duplicate some logic from love.draw
354 -- does not modify State (except to populate line_cache)
355 if line_index
< State
.screen_top1
.line
then return end
356 local loc2
= Text
.to2(State
, State
.screen_top1
)
359 if loc2
.line
== line_index
then return y
end
360 y
= y
+ State
.line_height
361 if y
+ State
.line_height
> App
.screen
.height
then break end
362 local next_loc2
= Text
.next_screen_line(State
, loc2
)
363 if Text
.eq2(next_loc2
, loc2
) then break end -- end of file
368 function Text
.previous_screen_top1(State
)
369 -- duplicate some logic from love.draw
370 -- does not modify State (except to populate line_cache)
371 local loc2
= Text
.to2(State
, State
.screen_top1
)
372 local y
= App
.screen
.height
- State
.line_height
373 while y
>= State
.top
do
374 if loc2
.line
== 1 and loc2
.screen_line
== 1 and loc2
.screen_pos
== 1 then break end
375 y
= y
- State
.line_height
376 loc2
= Text
.previous_screen_line(State
, loc2
)
378 return Text
.to1(State
, loc2
)
381 function Text
.pagedown(State
)
382 State
.screen_top1
= Text
.screen_bottom1(State
)
383 State
.cursor1
= deepcopy(State
.screen_top1
)
384 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
385 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
388 -- return the location of the start of the bottom-most line on screen
389 function Text
.screen_bottom1(State
)
390 -- duplicate some logic from love.draw
391 -- does not modify State (except to populate line_cache)
392 local loc2
= Text
.to2(State
, State
.screen_top1
)
395 y
= y
+ State
.line_height
396 if y
+ State
.line_height
> App
.screen
.height
then break end
397 local next_loc2
= Text
.next_screen_line(State
, loc2
)
398 if Text
.eq2(next_loc2
, loc2
) then break end
401 return Text
.to1(State
, loc2
)
404 function Text
.up(State
)
405 --? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
406 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
407 if screen_line_starting_pos
== 1 then
408 --? print('cursor is at first screen line of its line')
409 -- line is done; skip to previous text line
410 if State
.cursor1
.line
> 1 then
411 local new_cursor_line
= State
.cursor1
.line
-1
412 --? print('found previous text line')
413 State
.cursor1
= {line
=new_cursor_line
, pos
=nil}
414 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
415 -- previous text line found, pick its final screen line
416 --? print('has multiple screen lines')
417 local screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
418 --? print(#screen_line_starting_pos)
419 screen_line_starting_pos
= screen_line_starting_pos
[#screen_line_starting_pos
]
420 local screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_pos
)
421 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_byte_offset
)
422 State
.cursor1
.pos
= screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
425 -- move up one screen line in current line
426 assert(screen_line_index
> 1, 'bumped up against top screen line in line')
427 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
-1]
428 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
429 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
430 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
431 --? print('cursor pos is now '..tostring(State.cursor1.pos))
433 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
434 State
.screen_top1
= {
435 line
=State
.cursor1
.line
,
436 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
438 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
442 function Text
.down(State
)
443 --? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
444 assert(State
.cursor1
.pos
, 'cursor has no pos')
445 if Text
.cursor_at_final_screen_line(State
) then
446 -- line is done, skip to next text line
447 --? print('cursor at final screen line of its line')
448 if State
.cursor1
.line
< #State
.lines
then
449 local new_cursor_line
= State
.cursor1
.line
+1
450 State
.cursor1
.line
= new_cursor_line
451 State
.cursor1
.pos
= Text
.nearest_cursor_pos(State
.font
, State
.lines
[State
.cursor1
.line
].data
, State
.cursor_x
, State
.left
)
452 --? print(State.cursor1.pos)
454 local screen_bottom1
= Text
.screen_bottom1(State
)
455 --? print('down 2', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, screen_bottom1.line, screen_bottom1.pos)
456 if State
.cursor1
.line
> screen_bottom1
.line
then
457 --? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
458 --? print('scroll up preserving cursor')
459 Text
.snap_cursor_to_bottom_of_screen(State
)
460 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
463 -- move down one screen line in current line
464 local screen_bottom1
= Text
.screen_bottom1(State
)
465 local scroll_down
= Text
.le1(screen_bottom1
, State
.cursor1
)
466 --? print('cursor is NOT at final screen line of its line')
467 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
468 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
469 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
+1]
470 --? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
471 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
472 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
473 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
474 --? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
476 --? print('scroll up preserving cursor')
477 Text
.snap_cursor_to_bottom_of_screen(State
)
478 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
481 --? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
484 function Text
.start_of_line(State
)
485 State
.cursor1
.pos
= 1
486 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
487 State
.screen_top1
= deepcopy(State
.cursor1
)
491 function Text
.end_of_line(State
)
492 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) + 1
493 if Text
.cursor_out_of_screen(State
) then
494 Text
.snap_cursor_to_bottom_of_screen(State
)
498 function Text
.word_left(State
)
499 -- skip some whitespace
501 if State
.cursor1
.pos
== 1 then
504 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%S') then
509 -- skip some non-whitespace
512 if State
.cursor1
.pos
== 1 then
515 assert(State
.cursor1
.pos
> 1, 'bumped up against start of line')
516 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%s') then
522 function Text
.word_right(State
)
523 -- skip some whitespace
525 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
528 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%S') then
531 Text
.right_without_scroll(State
)
534 Text
.right_without_scroll(State
)
535 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
538 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%s') then
542 if Text
.cursor_out_of_screen(State
) then
543 Text
.snap_cursor_to_bottom_of_screen(State
)
547 function Text
.match(s
, pos
, pat
)
548 local start_offset
= Text
.offset(s
, pos
)
549 local end_offset
= Text
.offset(s
, pos
+1)
550 assert(end_offset
> start_offset
, ('end_offset %d not > start_offset %d'):format(end_offset
, start_offset
))
551 local curr
= s
:sub(start_offset
, end_offset
-1)
552 return curr
:match(pat
)
555 function Text
.left(State
)
556 if State
.cursor1
.pos
> 1 then
557 State
.cursor1
.pos
= State
.cursor1
.pos
-1
558 elseif State
.cursor1
.line
> 1 then
559 State
.cursor1
.line
= State
.cursor1
.line
-1
560 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) + 1
562 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
563 State
.screen_top1
= {
564 line
=State
.cursor1
.line
,
565 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
567 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
571 function Text
.right(State
)
572 Text
.right_without_scroll(State
)
573 if Text
.cursor_out_of_screen(State
) then
574 Text
.snap_cursor_to_bottom_of_screen(State
)
578 function Text
.right_without_scroll(State
)
579 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
580 State
.cursor1
.pos
= State
.cursor1
.pos
+1
581 elseif State
.cursor1
.line
<= #State
.lines
-1 then
582 State
.cursor1
.line
= State
.cursor1
.line
+1
583 State
.cursor1
.pos
= 1
587 -- result: pos, index of screen line
588 function Text
.pos_at_start_of_screen_line(State
, loc1
)
589 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
590 local line_cache
= State
.line_cache
[loc1
.line
]
591 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
592 local spos
= line_cache
.screen_line_starting_pos
[i
]
593 if spos
<= loc1
.pos
then
597 assert(false, ('invalid pos %d'):format(loc1
.pos
))
600 function Text
.pos_at_end_of_screen_line(State
, loc1
)
601 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
602 local line_cache
= State
.line_cache
[loc1
.line
]
603 local most_recent_final_pos
= utf8
.len(State
.lines
[loc1
.line
].data
)+1
604 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
605 local spos
= line_cache
.screen_line_starting_pos
[i
]
606 if spos
<= loc1
.pos
then
607 return most_recent_final_pos
609 most_recent_final_pos
= spos
-1
611 assert(false, ('invalid pos %d'):format(loc1
.pos
))
614 function Text
.final_text_loc_on_screen(State
)
615 local screen_bottom1
= Text
.screen_bottom1(State
)
617 line
=screen_bottom1
.line
,
618 pos
=Text
.pos_at_end_of_screen_line(State
, screen_bottom1
),
622 function Text
.cursor_at_final_screen_line(State
)
623 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
624 local screen_lines
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
625 --? print(screen_lines[#screen_lines], State.cursor1.pos)
626 return screen_lines
[#screen_lines
] <= State
.cursor1
.pos
629 function Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
630 if State
.top
> App
.screen
.height
- State
.line_height
then
631 --? print('scroll up')
632 Text
.snap_cursor_to_bottom_of_screen(State
)
636 -- should never modify State.cursor1
637 function Text
.snap_cursor_to_bottom_of_screen(State
)
638 --? print('to2:', State.cursor1.line, State.cursor1.pos)
639 local top2
= Text
.to2(State
, State
.cursor1
)
640 --? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
641 -- slide to start of screen line
642 top2
.screen_pos
= 1 -- start of screen line
643 --? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
644 --? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
645 local y
= App
.screen
.height
- State
.line_height
646 -- duplicate some logic from love.draw
648 --? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
649 if top2
.line
== 1 and top2
.screen_line
== 1 then break end
650 local h
= State
.line_height
651 if y
- h
< State
.top
then
655 top2
= Text
.previous_screen_line(State
, top2
)
657 --? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
658 State
.screen_top1
= Text
.to1(State
, top2
)
659 --? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
660 --? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
661 Text
.redraw_all(State
) -- if we're scrolling, reclaim all line caches to avoid memory leaks
664 function Text
.in_line(State
, line_index
, x
,y
)
665 local line
= State
.lines
[line_index
]
666 local line_cache
= State
.line_cache
[line_index
]
667 local starty
= Text
.starty(State
, line_index
)
668 if starty
== nil then return false end -- outside current page
669 if y
< starty
then return false end
670 Text
.populate_screen_line_starting_pos(State
, line_index
)
671 return y
< starty
+ State
.line_height
*(#line_cache
.screen_line_starting_pos
- Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
) + 1)
674 -- convert mx,my in pixels to schema-1 coordinates
675 function Text
.to_pos_on_line(State
, line_index
, mx
, my
)
676 local line
= State
.lines
[line_index
]
677 local line_cache
= State
.line_cache
[line_index
]
678 local starty
= Text
.starty(State
, line_index
)
679 assert(my
>= starty
, 'failed to map y pixel to line')
680 -- duplicate some logic from Text.draw
682 local start_screen_line_index
= Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
)
683 for screen_line_index
= start_screen_line_index
,#line_cache
.screen_line_starting_pos
do
684 local screen_line_starting_pos
= line_cache
.screen_line_starting_pos
[screen_line_index
]
685 local screen_line_starting_byte_offset
= Text
.offset(line
.data
, screen_line_starting_pos
)
686 --? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
687 local nexty
= y
+ State
.line_height
689 -- On all wrapped screen lines but the final one, clicks past end of
690 -- line position cursor on final character of screen line.
691 -- (The final screen line positions past end of screen line as always.)
692 if screen_line_index
< #line_cache
.screen_line_starting_pos
and mx
> State
.left
+ Text
.screen_line_width(State
, line_index
, screen_line_index
) then
693 --? print('past end of non-final line; return')
694 return line_cache
.screen_line_starting_pos
[screen_line_index
+1]
696 local s
= string.sub(line
.data
, screen_line_starting_byte_offset
)
697 --? print('return', mx, Text.nearest_cursor_pos(State.font, s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(State.font, s, mx, State.left) - 1)
698 return screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, mx
, State
.left
) - 1
702 assert(false, 'failed to map y pixel to line')
705 function Text
.screen_line_width(State
, line_index
, i
)
706 local line
= State
.lines
[line_index
]
707 local line_cache
= State
.line_cache
[line_index
]
708 local start_pos
= line_cache
.screen_line_starting_pos
[i
]
709 local start_offset
= Text
.offset(line
.data
, start_pos
)
711 if i
< #line_cache
.screen_line_starting_pos
then
712 local past_end_pos
= line_cache
.screen_line_starting_pos
[i
+1]
713 local past_end_offset
= Text
.offset(line
.data
, past_end_pos
)
714 screen_line
= string.sub(line
.data
, start_offset
, past_end_offset
-1)
716 screen_line
= string.sub(line
.data
, start_pos
)
718 return State
.font
:getWidth(screen_line
)
721 function Text
.screen_line_index(screen_line_starting_pos
, pos
)
722 for i
= #screen_line_starting_pos
,1,-1 do
723 if screen_line_starting_pos
[i
] <= pos
then
729 -- convert x pixel coordinate to pos
730 -- oblivious to wrapping
731 -- result: 1 to len+1
732 function Text
.nearest_cursor_pos(font
, line
, x
, left
)
736 local len
= utf8
.len(line
)
737 local max_x
= left
+Text
.x(font
, line
, len
+1)
741 local leftpos
, rightpos
= 1, len
+1
742 --? print('-- nearest', x)
744 --? print('nearest', x, '^'..line..'$', leftpos, rightpos)
745 if leftpos
== rightpos
then
748 local curr
= math
.floor((leftpos
+rightpos
)/2)
749 local currxmin
= left
+Text
.x(font
, line
, curr
)
750 local currxmax
= left
+Text
.x(font
, line
, curr
+1)
751 --? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
752 if currxmin
<= x
and x
< currxmax
then
753 if x
-currxmin
< currxmax
-x
then
759 if leftpos
>= rightpos
-1 then
768 assert(false, 'failed to map x pixel to pos')
771 -- return the nearest index of line (in utf8 code points) which lies entirely
772 -- within x pixels of the left margin
773 -- result: 0 to len+1
774 function Text
.nearest_pos_less_than(font
, line
, x
)
775 --? print('', '-- nearest_pos_less_than', line, x)
776 local len
= utf8
.len(line
)
777 local max_x
= Text
.x_after(font
, line
, len
)
781 local left
, right
= 0, len
+1
783 local curr
= math
.floor((left
+right
)/2)
784 local currxmin
= Text
.x_after(font
, line
, curr
+1)
785 local currxmax
= Text
.x_after(font
, line
, curr
+2)
786 --? print('', x, left, right, curr, currxmin, currxmax)
787 if currxmin
<= x
and x
< currxmax
then
790 if left
>= right
-1 then
799 assert(false, 'failed to map x pixel to pos')
802 function Text
.x_after(font
, s
, pos
)
803 local len
= utf8
.len(s
)
804 local offset
= Text
.offset(s
, math
.min(pos
+1, len
+1))
805 local s_before
= s
:sub(1, offset
-1)
806 --? print('^'..s_before..'$')
807 return font
:getWidth(s_before
)
810 function Text
.x(font
, s
, pos
)
811 local offset
= Text
.offset(s
, pos
)
812 local s_before
= s
:sub(1, offset
-1)
813 return font
:getWidth(s_before
)
816 function Text
.to2(State
, loc1
)
817 local result
= {line
=loc1
.line
}
818 local line_cache
= State
.line_cache
[loc1
.line
]
819 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
820 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
821 local spos
= line_cache
.screen_line_starting_pos
[i
]
822 if spos
<= loc1
.pos
then
823 result
.screen_line
= i
824 result
.screen_pos
= loc1
.pos
- spos
+ 1
828 assert(result
.screen_pos
, 'failed to convert schema-1 coordinate to schema-2')
832 function Text
.to1(State
, loc2
)
833 local result
= {line
=loc2
.line
, pos
=loc2
.screen_pos
}
834 if loc2
.screen_line
> 1 then
835 result
.pos
= State
.line_cache
[loc2
.line
].screen_line_starting_pos
[loc2
.screen_line
] + loc2
.screen_pos
- 1
840 function Text
.eq1(a
, b
)
841 return a
.line
== b
.line
and a
.pos
== b
.pos
844 function Text
.lt1(a
, b
)
845 if a
.line
< b
.line
then
848 if a
.line
> b
.line
then
854 function Text
.le1(a
, b
)
855 if a
.line
< b
.line
then
858 if a
.line
> b
.line
then
861 return a
.pos
<= b
.pos
864 function Text
.eq2(a
, b
)
865 return a
.line
== b
.line
and a
.screen_line
== b
.screen_line
and a
.screen_pos
== b
.screen_pos
868 function Text
.offset(s
, pos1
)
869 if pos1
== 1 then return 1 end
870 local result
= utf8
.offset(s
, pos1
)
871 if result
== nil then
872 assert(false, ('Text.offset(%d) called on a string of length %d (byte size %d); this is likely a failure to handle utf8\n\n^%s$\n'):format(pos1
, utf8
.len(s
), #s
, s
))
877 function Text
.previous_screen_line(State
, loc2
)
878 if loc2
.screen_line
> 1 then
879 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
-1, screen_pos
=1}
880 elseif loc2
.line
== 1 then
883 local l
= State
.lines
[loc2
.line
-1]
884 Text
.populate_screen_line_starting_pos(State
, loc2
.line
-1)
885 return {line
=loc2
.line
-1, screen_line
=#State
.line_cache
[loc2
.line
-1].screen_line_starting_pos
, screen_pos
=1}
889 function Text
.next_screen_line(State
, loc2
)
890 Text
.populate_screen_line_starting_pos(State
, loc2
.line
)
891 if loc2
.screen_line
>= #State
.line_cache
[loc2
.line
].screen_line_starting_pos
then
892 if loc2
.line
< #State
.lines
then
893 return {line
=loc2
.line
+1, screen_line
=1, screen_pos
=1}
898 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
+1, screen_pos
=1}
903 function Text
.tweak_screen_top_and_cursor(State
)
904 if State
.screen_top1
.pos
== 1 then return end
905 Text
.populate_screen_line_starting_pos(State
, State
.screen_top1
.line
)
906 local line
= State
.lines
[State
.screen_top1
.line
]
907 local line_cache
= State
.line_cache
[State
.screen_top1
.line
]
908 for i
=2,#line_cache
.screen_line_starting_pos
do
909 local pos
= line_cache
.screen_line_starting_pos
[i
]
910 if pos
== State
.screen_top1
.pos
then
913 if pos
> State
.screen_top1
.pos
then
914 -- make sure screen top is at start of a screen line
915 local prev
= line_cache
.screen_line_starting_pos
[i
-1]
916 if State
.screen_top1
.pos
- prev
< pos
- State
.screen_top1
.pos
then
917 State
.screen_top1
.pos
= prev
919 State
.screen_top1
.pos
= pos
924 -- make sure cursor is on screen
925 local screen_bottom1
= Text
.screen_bottom1(State
)
926 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
927 State
.cursor1
= deepcopy(State
.screen_top1
)
928 elseif State
.cursor1
.line
>= screen_bottom1
.line
then
929 if Text
.cursor_out_of_screen(State
) then
930 State
.cursor1
= Text
.final_text_loc_on_screen(State
)
935 -- slightly expensive since it redraws the screen
936 function Text
.cursor_out_of_screen(State
)
938 return State
.cursor_y
== nil
941 function Text
.redraw_all(State
)
942 --? print('clearing line caches')
943 -- Perform some early sanity checking here, in hopes that we correctly call
944 -- this whenever we change editor state.
945 if State
.right
<= State
.left
then
946 assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State
.right
, State
.left
))
949 State
.line_cache
= {}
950 for i
=1,#State
.lines
do
951 State
.line_cache
[i
] = {}
955 function Text
.clear_screen_line_cache(State
, line_index
)
956 State
.line_cache
[line_index
].screen_line_starting_pos
= nil
960 return s
:gsub('^%s+', ''):gsub('%s+$', '')
964 return s
:gsub('^%s+', '')
968 return s
:gsub('%s+$', '')
971 function starts_with(s
, prefix
)
976 if s
:sub(i
,i
) ~= prefix
:sub(i
,i
) then
983 function ends_with(s
, suffix
)
988 if s
:sub(#s
-i
,#s
-i
) ~= suffix
:sub(#suffix
-i
,#suffix
-i
) then