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
]
11 line_cache
.startpos
= startpos
13 Text
.populate_screen_line_starting_pos(State
, line_index
)
14 assert(#line_cache
.screen_line_starting_pos
>= 1, 'line cache missing screen line info')
15 for i
=1,#line_cache
.screen_line_starting_pos
do
16 local pos
= line_cache
.screen_line_starting_pos
[i
]
17 if pos
< startpos
then
20 local screen_line
= Text
.screen_line(line
, line_cache
, i
)
21 --? print('text.draw:', screen_line, 'at', line_index,pos, 'after', x,y)
22 local frag_len
= utf8
.len(screen_line
)
23 -- render any highlights
24 if State
.selection1
.line
then
25 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
26 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
28 if line_index
== State
.cursor1
.line
then
29 -- render search highlight or cursor
30 if State
.search_term
then
31 local data
= State
.lines
[State
.cursor1
.line
].data
32 local cursor_offset
= Text
.offset(data
, State
.cursor1
.pos
)
33 if data
:sub(cursor_offset
, cursor_offset
+#State
.search_term
-1) == State
.search_term
then
34 local save_selection
= State
.selection1
35 State
.selection1
= {line
=line_index
, pos
=State
.cursor1
.pos
+utf8
.len(State
.search_term
)}
36 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
37 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
38 State
.selection1
= save_selection
41 if pos
<= State
.cursor1
.pos
and pos
+ frag_len
> State
.cursor1
.pos
then
42 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
43 elseif pos
+ frag_len
== State
.cursor1
.pos
then
44 -- Show cursor at end of line.
45 -- This place also catches end of wrapping screen lines. That doesn't seem worth distinguishing.
46 -- It seems useful to see a cursor whether your eye is on the left or right margin.
47 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
53 App
.screen
.print(screen_line
, State
.left
,y
)
54 y
= y
+ State
.line_height
55 if y
>= App
.screen
.height
then
63 function Text
.screen_line(line
, line_cache
, i
)
64 local pos
= line_cache
.screen_line_starting_pos
[i
]
65 local offset
= Text
.offset(line
.data
, pos
)
66 if i
>= #line_cache
.screen_line_starting_pos
then
67 return line
.data
:sub(offset
)
69 local endpos
= line_cache
.screen_line_starting_pos
[i
+1]-1
70 local end_offset
= Text
.offset(line
.data
, endpos
)
71 return line
.data
:sub(offset
, end_offset
)
74 function Text
.draw_cursor(State
, x
, y
)
76 if math
.floor(Cursor_time
*2)%2 == 0 then
77 App
.color(Cursor_color
)
78 love
.graphics
.rectangle('fill', x
,y
, 3,State
.line_height
)
81 State
.cursor_y
= y
+State
.line_height
84 function Text
.populate_screen_line_starting_pos(State
, line_index
)
85 local line
= State
.lines
[line_index
]
86 if line
.mode
~= 'text' then return end
87 local line_cache
= State
.line_cache
[line_index
]
88 if line_cache
.screen_line_starting_pos
then
91 line_cache
.screen_line_starting_pos
= {1}
94 -- try to wrap at word boundaries
95 for frag
in line
.data
:gmatch('%S*%s*') do
96 local frag_width
= State
.font
:getWidth(frag
)
97 --? print('-- frag:', frag, pos, x, frag_width, State.width)
98 while x
+ frag_width
> State
.width
do
99 --? print('frag:', frag, pos, x, frag_width, State.width)
100 if x
< 0.8 * State
.width
then
101 -- long word; chop it at some letter
102 -- We're not going to reimplement TeX here.
103 local bpos
= Text
.nearest_pos_less_than(State
.font
, frag
, State
.width
- x
)
104 if x
== 0 and bpos
== 0 then
105 assert(false, ("Infinite loop while line-wrapping. Editor is %dpx wide; window is %dpx wide"):format(State
.width
, App
.screen
.width
))
108 local boffset
= Text
.offset(frag
, bpos
+1) -- byte _after_ bpos
109 frag
= string.sub(frag
, boffset
)
111 --? print('after chop:', frag)
113 frag_width
= State
.font
:getWidth(frag
)
115 --? print('screen line:', pos)
116 table.insert(line_cache
.screen_line_starting_pos
, pos
)
117 x
= 0 -- new screen line
120 pos
= pos
+ utf8
.len(frag
)
124 function Text
.text_input(State
, t
)
125 if App
.mouse_down(1) then return end
126 if App
.any_modifier_down() then
127 if App
.key_down(t
) then
128 -- The modifiers didn't change the key. Handle it in keychord_pressed.
131 -- Key mutated by the keyboard layout. Continue below.
134 local before
= snapshot(State
, State
.cursor1
.line
)
135 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
136 Text
.insert_at_cursor(State
, t
)
137 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
138 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
139 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
141 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
144 function Text
.insert_at_cursor(State
, t
)
145 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
146 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
147 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
)
148 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
149 State
.cursor1
.pos
= State
.cursor1
.pos
+1
152 -- Don't handle any keys here that would trigger text_input above.
153 function Text
.keychord_press(State
, chord
)
154 --? print('chord', chord, State.selection1.line, State.selection1.pos)
155 --== shortcuts that mutate text
156 if chord
== 'return' then
157 local before_line
= State
.cursor1
.line
158 local before
= snapshot(State
, before_line
)
159 Text
.insert_return(State
)
160 State
.selection1
= {}
161 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
162 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
165 record_undo_event(State
, {before
=before
, after
=snapshot(State
, before_line
, State
.cursor1
.line
)})
166 elseif chord
== 'tab' then
167 local before
= snapshot(State
, State
.cursor1
.line
)
168 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
169 Text
.insert_at_cursor(State
, '\t')
170 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
171 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
172 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
173 --? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
176 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
177 elseif chord
== 'backspace' then
178 if State
.selection1
.line
then
179 Text
.delete_selection(State
, State
.left
, State
.right
)
184 if State
.cursor1
.pos
> 1 then
185 before
= snapshot(State
, State
.cursor1
.line
)
186 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1)
187 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
190 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
)
192 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
194 State
.cursor1
.pos
= State
.cursor1
.pos
-1
196 elseif State
.cursor1
.line
> 1 then
197 before
= snapshot(State
, State
.cursor1
.line
-1, State
.cursor1
.line
)
198 if State
.lines
[State
.cursor1
.line
-1].mode
== 'drawing' then
199 table.remove(State
.lines
, State
.cursor1
.line
-1)
200 table.remove(State
.line_cache
, State
.cursor1
.line
-1)
203 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
-1].data
)+1
204 State
.lines
[State
.cursor1
.line
-1].data
= State
.lines
[State
.cursor1
.line
-1].data
..State
.lines
[State
.cursor1
.line
].data
205 table.remove(State
.lines
, State
.cursor1
.line
)
206 table.remove(State
.line_cache
, State
.cursor1
.line
)
208 State
.cursor1
.line
= State
.cursor1
.line
-1
210 if State
.screen_top1
.line
> #State
.lines
then
211 Text
.populate_screen_line_starting_pos(State
, #State
.lines
)
212 local line_cache
= State
.line_cache
[#State
.line_cache
]
213 State
.screen_top1
= {line
=#State
.lines
, pos
=line_cache
.screen_line_starting_pos
[#line_cache
.screen_line_starting_pos
]}
214 elseif Text
.lt1(State
.cursor1
, State
.screen_top1
) then
215 State
.screen_top1
= {
216 line
=State
.cursor1
.line
,
217 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
219 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
221 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
222 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
))
224 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
225 elseif chord
== 'delete' then
226 if State
.selection1
.line
then
227 Text
.delete_selection(State
, State
.left
, State
.right
)
232 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
233 before
= snapshot(State
, State
.cursor1
.line
)
235 before
= snapshot(State
, State
.cursor1
.line
, State
.cursor1
.line
+1)
237 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
238 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
239 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
+1)
242 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
)
244 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
246 -- no change to State.cursor1.pos
248 elseif State
.cursor1
.line
< #State
.lines
then
249 if State
.lines
[State
.cursor1
.line
+1].mode
== 'text' then
251 State
.lines
[State
.cursor1
.line
].data
= State
.lines
[State
.cursor1
.line
].data
..State
.lines
[State
.cursor1
.line
+1].data
253 table.remove(State
.lines
, State
.cursor1
.line
+1)
254 table.remove(State
.line_cache
, State
.cursor1
.line
+1)
256 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
258 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
259 --== shortcuts that move the cursor
260 elseif chord
== 'left' then
262 State
.selection1
= {}
263 elseif chord
== 'right' then
265 State
.selection1
= {}
266 elseif chord
== 'S-left' then
267 if State
.selection1
.line
== nil then
268 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
271 elseif chord
== 'S-right' then
272 if State
.selection1
.line
== nil then
273 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
276 -- C- hotkeys reserved for drawings, so we'll use M-
277 elseif chord
== 'M-left' then
278 Text
.word_left(State
)
279 State
.selection1
= {}
280 elseif chord
== 'M-right' then
281 Text
.word_right(State
)
282 State
.selection1
= {}
283 elseif chord
== 'M-S-left' then
284 if State
.selection1
.line
== nil then
285 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
287 Text
.word_left(State
)
288 elseif chord
== 'M-S-right' then
289 if State
.selection1
.line
== nil then
290 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
292 Text
.word_right(State
)
293 elseif chord
== 'home' then
294 Text
.start_of_line(State
)
295 State
.selection1
= {}
296 elseif chord
== 'end' then
297 Text
.end_of_line(State
)
298 State
.selection1
= {}
299 elseif chord
== 'S-home' then
300 if State
.selection1
.line
== nil then
301 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
303 Text
.start_of_line(State
)
304 elseif chord
== 'S-end' then
305 if State
.selection1
.line
== nil then
306 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
308 Text
.end_of_line(State
)
309 elseif chord
== 'up' then
311 State
.selection1
= {}
312 elseif chord
== 'down' then
314 State
.selection1
= {}
315 elseif chord
== 'S-up' then
316 if State
.selection1
.line
== nil then
317 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
320 elseif chord
== 'S-down' then
321 if State
.selection1
.line
== nil then
322 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
325 elseif chord
== 'pageup' then
327 State
.selection1
= {}
328 elseif chord
== 'pagedown' then
330 State
.selection1
= {}
331 elseif chord
== 'S-pageup' then
332 if State
.selection1
.line
== nil then
333 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
336 elseif chord
== 'S-pagedown' then
337 if State
.selection1
.line
== nil then
338 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
344 function Text
.insert_return(State
)
345 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
346 table.insert(State
.lines
, State
.cursor1
.line
+1, {mode
='text', data
=string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_offset
)})
347 table.insert(State
.line_cache
, State
.cursor1
.line
+1, {})
348 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_offset
-1)
349 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
350 State
.cursor1
= {line
=State
.cursor1
.line
+1, pos
=1}
353 function Text
.pageup(State
)
354 State
.screen_top1
= Text
.previous_screen_top1(State
)
355 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
356 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
357 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
360 function Text
.previous_screen_top1(State
)
361 -- duplicate some logic from love.draw
362 -- does not modify State (except to populate line_cache)
363 local loc2
= Text
.to2(State
, State
.screen_top1
)
364 local y
= App
.screen
.height
- State
.line_height
365 while y
>= State
.top
do
366 if loc2
.line
== 1 and loc2
.screen_line
== 1 and loc2
.screen_pos
== 1 then break end
367 if State
.lines
[loc2
.line
].mode
== 'text' then
368 y
= y
- State
.line_height
369 elseif State
.lines
[loc2
.line
].mode
== 'drawing' then
370 y
= y
- Drawing_padding_height
- Drawing
.pixels(State
.lines
[loc2
.line
].h
, State
.width
)
372 loc2
= Text
.previous_screen_line(State
, loc2
)
374 return Text
.to1(State
, loc2
)
377 function Text
.pagedown(State
)
378 State
.screen_top1
= Text
.screen_bottom1(State
)
379 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
380 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
381 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
384 -- return the location of the start of the bottom-most line on screen
385 function Text
.screen_bottom1(State
)
386 -- duplicate some logic from love.draw
387 -- does not modify State (except to populate line_cache)
388 local loc2
= Text
.to2(State
, State
.screen_top1
)
391 if State
.lines
[loc2
.line
].mode
== 'text' then
392 y
= y
+ State
.line_height
393 elseif State
.lines
[loc2
.line
].mode
== 'drawing' then
394 y
= y
+ Drawing_padding_height
+ Drawing
.pixels(State
.lines
[loc2
.line
].h
, State
.width
)
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 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
406 --? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
407 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
408 if screen_line_starting_pos
== 1 then
409 --? print('cursor is at first screen line of its line')
410 -- line is done; skip to previous text line
411 local new_cursor_line
= State
.cursor1
.line
412 while new_cursor_line
> 1 do
413 new_cursor_line
= new_cursor_line
-1
414 if State
.lines
[new_cursor_line
].mode
== 'text' then
415 --? print('found previous text line')
416 State
.cursor1
= {line
=new_cursor_line
, pos
=nil}
417 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
418 -- previous text line found, pick its final screen line
419 --? print('has multiple screen lines')
420 local screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
421 --? print(#screen_line_starting_pos)
422 screen_line_starting_pos
= screen_line_starting_pos
[#screen_line_starting_pos
]
423 local screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_pos
)
424 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_byte_offset
)
425 State
.cursor1
.pos
= screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
430 -- move up one screen line in current line
431 assert(screen_line_index
> 1, 'bumped up against top screen line in line')
432 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
-1]
433 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
434 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
435 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
436 --? print('cursor pos is now '..tostring(State.cursor1.pos))
438 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
439 State
.screen_top1
= {
440 line
=State
.cursor1
.line
,
441 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
443 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
447 function Text
.down(State
)
448 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
449 --? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
450 assert(State
.cursor1
.pos
, 'cursor has no pos')
451 if Text
.cursor_at_final_screen_line(State
) then
452 -- line is done, skip to next text line
453 --? print('cursor at final screen line of its line')
454 local new_cursor_line
= State
.cursor1
.line
455 while new_cursor_line
< #State
.lines
do
456 new_cursor_line
= new_cursor_line
+1
457 if State
.lines
[new_cursor_line
].mode
== 'text' then
459 line
= new_cursor_line
,
460 pos
= Text
.nearest_cursor_pos(State
.font
, State
.lines
[new_cursor_line
].data
, State
.cursor_x
, State
.left
),
462 --? print(State.cursor1.pos)
466 local screen_bottom1
= Text
.screen_bottom1(State
)
467 --? print('down 2', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, screen_bottom1.line, screen_bottom1.pos)
468 if State
.cursor1
.line
> screen_bottom1
.line
then
469 --? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
470 --? print('scroll up preserving cursor')
471 Text
.snap_cursor_to_bottom_of_screen(State
)
472 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
475 -- move down one screen line in current line
476 local screen_bottom1
= Text
.screen_bottom1(State
)
477 local scroll_down
= Text
.le1(screen_bottom1
, State
.cursor1
)
478 --? print('cursor is NOT at final screen line of its line')
479 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
480 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
481 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
+1]
482 --? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
483 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
484 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
485 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
486 --? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
488 --? print('scroll up preserving cursor')
489 Text
.snap_cursor_to_bottom_of_screen(State
)
490 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
493 --? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
496 function Text
.start_of_line(State
)
497 State
.cursor1
.pos
= 1
498 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
499 State
.screen_top1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
} -- copy
503 function Text
.end_of_line(State
)
504 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) + 1
505 if Text
.cursor_out_of_screen(State
) then
506 Text
.snap_cursor_to_bottom_of_screen(State
)
510 function Text
.word_left(State
)
511 -- skip some whitespace
513 if State
.cursor1
.pos
== 1 then
516 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%S') then
521 -- skip some non-whitespace
524 if State
.cursor1
.pos
== 1 then
527 assert(State
.cursor1
.pos
> 1, 'bumped up against start of line')
528 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%s') then
534 function Text
.word_right(State
)
535 -- skip some whitespace
537 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
540 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%S') then
543 Text
.right_without_scroll(State
)
546 Text
.right_without_scroll(State
)
547 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
550 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%s') then
554 if Text
.cursor_out_of_screen(State
) then
555 Text
.snap_cursor_to_bottom_of_screen(State
)
559 function Text
.match(s
, pos
, pat
)
560 local start_offset
= Text
.offset(s
, pos
)
561 local end_offset
= Text
.offset(s
, pos
+1)
562 assert(end_offset
> start_offset
, ('end_offset %d not > start_offset %d'):format(end_offset
, start_offset
))
563 local curr
= s
:sub(start_offset
, end_offset
-1)
564 return curr
:match(pat
)
567 function Text
.left(State
)
568 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
569 if State
.cursor1
.pos
> 1 then
570 State
.cursor1
.pos
= State
.cursor1
.pos
-1
572 local new_cursor_line
= State
.cursor1
.line
573 while new_cursor_line
> 1 do
574 new_cursor_line
= new_cursor_line
-1
575 if State
.lines
[new_cursor_line
].mode
== 'text' then
577 line
= new_cursor_line
,
578 pos
= utf8
.len(State
.lines
[new_cursor_line
].data
) + 1,
584 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
585 State
.screen_top1
= {
586 line
=State
.cursor1
.line
,
587 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
589 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
593 function Text
.right(State
)
594 Text
.right_without_scroll(State
)
595 if Text
.cursor_out_of_screen(State
) then
596 Text
.snap_cursor_to_bottom_of_screen(State
)
600 function Text
.right_without_scroll(State
)
601 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
602 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
603 State
.cursor1
.pos
= State
.cursor1
.pos
+1
605 local new_cursor_line
= State
.cursor1
.line
606 while new_cursor_line
<= #State
.lines
-1 do
607 new_cursor_line
= new_cursor_line
+1
608 if State
.lines
[new_cursor_line
].mode
== 'text' then
609 State
.cursor1
= {line
=new_cursor_line
, pos
=1}
616 -- result: pos, index of screen line
617 function Text
.pos_at_start_of_screen_line(State
, loc1
)
618 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
619 local line_cache
= State
.line_cache
[loc1
.line
]
620 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
621 local spos
= line_cache
.screen_line_starting_pos
[i
]
622 if spos
<= loc1
.pos
then
626 assert(false, ('invalid pos %d'):format(loc1
.pos
))
629 function Text
.pos_at_end_of_screen_line(State
, loc1
)
630 assert(State
.lines
[loc1
.line
].mode
== 'text')
631 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
632 local line_cache
= State
.line_cache
[loc1
.line
]
633 local most_recent_final_pos
= utf8
.len(State
.lines
[loc1
.line
].data
)+1
634 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
635 local spos
= line_cache
.screen_line_starting_pos
[i
]
636 if spos
<= loc1
.pos
then
637 return most_recent_final_pos
639 most_recent_final_pos
= spos
-1
641 assert(false, ('invalid pos %d'):format(loc1
.pos
))
644 function Text
.final_text_loc_on_screen(State
)
645 local screen_bottom1
= Text
.screen_bottom1(State
)
646 if State
.lines
[screen_bottom1
.line
].mode
== 'text' then
648 line
=screen_bottom1
.line
,
649 pos
=Text
.pos_at_end_of_screen_line(State
, screen_bottom1
),
652 local loc2
= Text
.to2(State
, screen_bottom1
)
654 if State
.lines
[loc2
.line
].mode
== 'text' then break end
655 assert(loc2
.line
> 1 or loc2
.screen_line
> 1 and loc2
.screen_pos
> 1) -- elsewhere we're making sure there's always at least one text line on screen
656 loc2
= Text
.previous_screen_line(State
, loc2
)
658 local result
= Text
.to1(State
, loc2
)
659 result
.pos
= Text
.pos_at_end_of_screen_line(State
, result
)
663 function Text
.cursor_at_final_screen_line(State
)
664 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
665 local screen_lines
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
666 --? print(screen_lines[#screen_lines], State.cursor1.pos)
667 return screen_lines
[#screen_lines
] <= State
.cursor1
.pos
670 function Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
672 while State
.cursor1
.line
<= #State
.lines
do
673 if State
.lines
[State
.cursor1
.line
].mode
== 'text' then
676 --? print('cursor skips', State.cursor1.line)
677 y
= y
+ Drawing_padding_height
+ Drawing
.pixels(State
.lines
[State
.cursor1
.line
].h
, State
.width
)
678 State
.cursor1
.line
= State
.cursor1
.line
+ 1
680 if State
.cursor1
.pos
== nil then
681 State
.cursor1
.pos
= 1
683 -- hack: insert a text line at bottom of file if necessary
684 if State
.cursor1
.line
> #State
.lines
then
685 assert(State
.cursor1
.line
== #State
.lines
+1, 'tried to ensure bottom line of file is text, but failed')
686 table.insert(State
.lines
, {mode
='text', data
=''})
687 table.insert(State
.line_cache
, {})
689 --? print(y, App.screen.height, App.screen.height-State.line_height)
690 if y
> App
.screen
.height
- State
.line_height
then
691 --? print('scroll up')
692 Text
.snap_cursor_to_bottom_of_screen(State
)
696 -- should never modify State.cursor1
697 function Text
.snap_cursor_to_bottom_of_screen(State
)
698 --? print('to2:', State.cursor1.line, State.cursor1.pos)
699 local top2
= Text
.to2(State
, State
.cursor1
)
700 --? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
701 -- slide to start of screen line
702 top2
.screen_pos
= 1 -- start of screen line
703 --? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
704 --? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
705 local y
= App
.screen
.height
- State
.line_height
706 -- duplicate some logic from love.draw
708 --? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
709 if top2
.line
== 1 and top2
.screen_line
== 1 then break end
710 if top2
.screen_line
> 1 or State
.lines
[top2
.line
-1].mode
== 'text' then
711 local h
= State
.line_height
712 if y
- h
< State
.top
then
717 assert(top2
.line
> 1, 'tried to snap cursor to buttom of screen but failed')
718 assert(State
.lines
[top2
.line
-1].mode
== 'drawing', "expected a drawing but it's not")
719 -- We currently can't draw partial drawings, so either skip it entirely
721 local h
= Drawing_padding_height
+ Drawing
.pixels(State
.lines
[top2
.line
-1].h
, State
.width
)
722 if y
- h
< State
.top
then
725 --? print('skipping drawing of height', h)
728 top2
= Text
.previous_screen_line(State
, top2
)
730 --? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
731 State
.screen_top1
= Text
.to1(State
, top2
)
732 --? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
733 --? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos)
734 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
737 function Text
.in_line(State
, line_index
, x
,y
)
738 local line
= State
.lines
[line_index
]
739 local line_cache
= State
.line_cache
[line_index
]
740 if line_cache
.starty
== nil then return false end -- outside current page
741 if y
< line_cache
.starty
then return false end
742 Text
.populate_screen_line_starting_pos(State
, line_index
)
743 return y
< line_cache
.starty
+ State
.line_height
*(#line_cache
.screen_line_starting_pos
- Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
) + 1)
746 -- convert mx,my in pixels to schema-1 coordinates
747 function Text
.to_pos_on_line(State
, line_index
, mx
, my
)
748 local line
= State
.lines
[line_index
]
749 local line_cache
= State
.line_cache
[line_index
]
750 assert(my
>= line_cache
.starty
, 'failed to map y pixel to line')
751 -- duplicate some logic from Text.draw
752 local y
= line_cache
.starty
753 local start_screen_line_index
= Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
)
754 for screen_line_index
= start_screen_line_index
,#line_cache
.screen_line_starting_pos
do
755 local screen_line_starting_pos
= line_cache
.screen_line_starting_pos
[screen_line_index
]
756 local screen_line_starting_byte_offset
= Text
.offset(line
.data
, screen_line_starting_pos
)
757 --? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
758 local nexty
= y
+ State
.line_height
760 -- On all wrapped screen lines but the final one, clicks past end of
761 -- line position cursor on final character of screen line.
762 -- (The final screen line positions past end of screen line as always.)
763 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
764 --? print('past end of non-final line; return')
765 return line_cache
.screen_line_starting_pos
[screen_line_index
+1]
767 local s
= string.sub(line
.data
, screen_line_starting_byte_offset
)
768 --? 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)
769 return screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, mx
, State
.left
) - 1
773 assert(false, 'failed to map y pixel to line')
776 function Text
.screen_line_width(State
, line_index
, i
)
777 local line
= State
.lines
[line_index
]
778 local line_cache
= State
.line_cache
[line_index
]
779 local start_pos
= line_cache
.screen_line_starting_pos
[i
]
780 local start_offset
= Text
.offset(line
.data
, start_pos
)
782 if i
< #line_cache
.screen_line_starting_pos
then
783 local past_end_pos
= line_cache
.screen_line_starting_pos
[i
+1]
784 local past_end_offset
= Text
.offset(line
.data
, past_end_pos
)
785 screen_line
= string.sub(line
.data
, start_offset
, past_end_offset
-1)
787 screen_line
= string.sub(line
.data
, start_pos
)
789 return State
.font
:getWidth(screen_line
)
792 function Text
.screen_line_index(screen_line_starting_pos
, pos
)
793 for i
= #screen_line_starting_pos
,1,-1 do
794 if screen_line_starting_pos
[i
] <= pos
then
800 -- convert x pixel coordinate to pos
801 -- oblivious to wrapping
802 -- result: 1 to len+1
803 function Text
.nearest_cursor_pos(font
, line
, x
, left
)
807 local len
= utf8
.len(line
)
808 local max_x
= left
+Text
.x(font
, line
, len
+1)
812 local leftpos
, rightpos
= 1, len
+1
813 --? print('-- nearest', x)
815 --? print('nearest', x, '^'..line..'$', leftpos, rightpos)
816 if leftpos
== rightpos
then
819 local curr
= math
.floor((leftpos
+rightpos
)/2)
820 local currxmin
= left
+Text
.x(font
, line
, curr
)
821 local currxmax
= left
+Text
.x(font
, line
, curr
+1)
822 --? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
823 if currxmin
<= x
and x
< currxmax
then
824 if x
-currxmin
< currxmax
-x
then
830 if leftpos
>= rightpos
-1 then
839 assert(false, 'failed to map x pixel to pos')
842 -- return the nearest index of line (in utf8 code points) which lies entirely
843 -- within x pixels of the left margin
844 -- result: 0 to len+1
845 function Text
.nearest_pos_less_than(font
, line
, x
)
846 --? print('', '-- nearest_pos_less_than', line, x)
847 local len
= utf8
.len(line
)
848 local max_x
= Text
.x_after(font
, line
, len
)
852 local left
, right
= 0, len
+1
854 local curr
= math
.floor((left
+right
)/2)
855 local currxmin
= Text
.x_after(font
, line
, curr
+1)
856 local currxmax
= Text
.x_after(font
, line
, curr
+2)
857 --? print('', x, left, right, curr, currxmin, currxmax)
858 if currxmin
<= x
and x
< currxmax
then
861 if left
>= right
-1 then
870 assert(false, 'failed to map x pixel to pos')
873 function Text
.x_after(font
, s
, pos
)
874 local len
= utf8
.len(s
)
875 local offset
= Text
.offset(s
, math
.min(pos
+1, len
+1))
876 local s_before
= s
:sub(1, offset
-1)
877 --? print('^'..s_before..'$')
878 return font
:getWidth(s_before
)
881 function Text
.x(font
, s
, pos
)
882 local offset
= Text
.offset(s
, pos
)
883 local s_before
= s
:sub(1, offset
-1)
884 return font
:getWidth(s_before
)
887 function Text
.to2(State
, loc1
)
888 if State
.lines
[loc1
.line
].mode
== 'drawing' then
889 return {line
=loc1
.line
, screen_line
=1, screen_pos
=1}
891 local result
= {line
=loc1
.line
}
892 local line_cache
= State
.line_cache
[loc1
.line
]
893 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
894 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
895 local spos
= line_cache
.screen_line_starting_pos
[i
]
896 if spos
<= loc1
.pos
then
897 result
.screen_line
= i
898 result
.screen_pos
= loc1
.pos
- spos
+ 1
902 assert(result
.screen_pos
, 'failed to convert schema-1 coordinate to schema-2')
906 function Text
.to1(State
, loc2
)
907 local result
= {line
=loc2
.line
, pos
=loc2
.screen_pos
}
908 if loc2
.screen_line
> 1 then
909 result
.pos
= State
.line_cache
[loc2
.line
].screen_line_starting_pos
[loc2
.screen_line
] + loc2
.screen_pos
- 1
914 function Text
.eq1(a
, b
)
915 return a
.line
== b
.line
and a
.pos
== b
.pos
918 function Text
.lt1(a
, b
)
919 if a
.line
< b
.line
then
922 if a
.line
> b
.line
then
928 function Text
.le1(a
, b
)
929 if a
.line
< b
.line
then
932 if a
.line
> b
.line
then
935 return a
.pos
<= b
.pos
938 function Text
.eq2(a
, b
)
939 return a
.line
== b
.line
and a
.screen_line
== b
.screen_line
and a
.screen_pos
== b
.screen_pos
942 function Text
.offset(s
, pos1
)
943 if pos1
== 1 then return 1 end
944 local result
= utf8
.offset(s
, pos1
)
945 if result
== nil then
946 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
))
951 function Text
.previous_screen_line(State
, loc2
)
952 if loc2
.screen_line
> 1 then
953 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
-1, screen_pos
=1}
954 elseif loc2
.line
== 1 then
956 elseif State
.lines
[loc2
.line
-1].mode
== 'drawing' then
957 return {line
=loc2
.line
-1, screen_line
=1, screen_pos
=1}
959 local l
= State
.lines
[loc2
.line
-1]
960 Text
.populate_screen_line_starting_pos(State
, loc2
.line
-1)
961 return {line
=loc2
.line
-1, screen_line
=#State
.line_cache
[loc2
.line
-1].screen_line_starting_pos
, screen_pos
=1}
965 function Text
.next_screen_line(State
, loc2
)
966 if State
.lines
[loc2
.line
].mode
== 'drawing' then
967 return {line
=loc2
.line
+1, screen_line
=1, screen_pos
=1}
969 Text
.populate_screen_line_starting_pos(State
, loc2
.line
)
970 if loc2
.screen_line
>= #State
.line_cache
[loc2
.line
].screen_line_starting_pos
then
971 if loc2
.line
< #State
.lines
then
972 return {line
=loc2
.line
+1, screen_line
=1, screen_pos
=1}
977 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
+1, screen_pos
=1}
982 function Text
.tweak_screen_top_and_cursor(State
)
983 if State
.screen_top1
.pos
== 1 then return end
984 Text
.populate_screen_line_starting_pos(State
, State
.screen_top1
.line
)
985 local line
= State
.lines
[State
.screen_top1
.line
]
986 local line_cache
= State
.line_cache
[State
.screen_top1
.line
]
987 for i
=2,#line_cache
.screen_line_starting_pos
do
988 local pos
= line_cache
.screen_line_starting_pos
[i
]
989 if pos
== State
.screen_top1
.pos
then
992 if pos
> State
.screen_top1
.pos
then
993 -- make sure screen top is at start of a screen line
994 local prev
= line_cache
.screen_line_starting_pos
[i
-1]
995 if State
.screen_top1
.pos
- prev
< pos
- State
.screen_top1
.pos
then
996 State
.screen_top1
.pos
= prev
998 State
.screen_top1
.pos
= pos
1003 -- make sure cursor is on screen
1004 local screen_bottom1
= Text
.screen_bottom1(State
)
1005 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
1006 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
1007 elseif State
.cursor1
.line
>= screen_bottom1
.line
then
1008 if Text
.cursor_out_of_screen(State
) then
1009 State
.cursor1
= Text
.final_text_loc_on_screen(State
)
1014 -- slightly expensive since it redraws the screen
1015 function Text
.cursor_out_of_screen(State
)
1017 return State
.cursor_y
== nil
1020 function Text
.redraw_all(State
)
1021 --? print('clearing fragments')
1022 -- Perform some early sanity checking here, in hopes that we correctly call
1023 -- this whenever we change editor state.
1024 if State
.right
<= State
.left
then
1025 assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State
.right
, State
.left
))
1028 State
.line_cache
= {}
1029 for i
=1,#State
.lines
do
1030 State
.line_cache
[i
] = {}
1034 function Text
.clear_screen_line_cache(State
, line_index
)
1035 State
.line_cache
[line_index
].screen_line_starting_pos
= nil
1039 return s
:gsub('^%s+', ''):gsub('%s+$', '')
1043 return s
:gsub('^%s+', '')
1047 return s
:gsub('%s+$', '')
1050 function starts_with(s
, prefix
)
1051 if #s
< #prefix
then
1055 if s
:sub(i
,i
) ~= prefix
:sub(i
,i
) then
1062 function ends_with(s
, suffix
)
1063 if #s
< #suffix
then
1066 for i
=0,#suffix
-1 do
1067 if s
:sub(#s
-i
,#s
-i
) ~= suffix
:sub(#suffix
-i
,#suffix
-i
) then