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, and position of start of final screen line drawn
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 local final_screen_line_starting_pos
= startpos
-- track value to return
14 Text
.populate_screen_line_starting_pos(State
, line_index
)
15 assert(#line_cache
.screen_line_starting_pos
>= 1, 'line cache missing screen line info')
16 for i
=1,#line_cache
.screen_line_starting_pos
do
17 local pos
= line_cache
.screen_line_starting_pos
[i
]
18 if pos
< startpos
then
21 final_screen_line_starting_pos
= pos
22 local screen_line
= Text
.screen_line(line
, line_cache
, i
)
23 --? print('text.draw:', screen_line, 'at', line_index,pos, 'after', x,y)
24 local frag_len
= utf8
.len(screen_line
)
25 -- render any highlights
26 if State
.selection1
.line
then
27 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
28 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
30 if line_index
== State
.cursor1
.line
then
31 -- render search highlight or cursor
32 if State
.search_term
then
33 local data
= State
.lines
[State
.cursor1
.line
].data
34 local cursor_offset
= Text
.offset(data
, State
.cursor1
.pos
)
35 if data
:sub(cursor_offset
, cursor_offset
+#State
.search_term
-1) == State
.search_term
then
36 local save_selection
= State
.selection1
37 State
.selection1
= {line
=line_index
, pos
=State
.cursor1
.pos
+utf8
.len(State
.search_term
)}
38 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
39 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
40 State
.selection1
= save_selection
43 if pos
<= State
.cursor1
.pos
and pos
+ frag_len
> State
.cursor1
.pos
then
44 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
45 elseif pos
+ frag_len
== State
.cursor1
.pos
then
46 -- Show cursor at end of line.
47 -- This place also catches end of wrapping screen lines. That doesn't seem worth distinguishing.
48 -- It seems useful to see a cursor whether your eye is on the left or right margin.
49 Text
.draw_cursor(State
, State
.left
+Text
.x(State
.font
, screen_line
, State
.cursor1
.pos
-pos
+1), y
)
55 App
.screen
.print(screen_line
, State
.left
,y
)
56 y
= y
+ State
.line_height
57 if y
>= App
.screen
.height
then
62 return y
, final_screen_line_starting_pos
65 function Text
.screen_line(line
, line_cache
, i
)
66 local pos
= line_cache
.screen_line_starting_pos
[i
]
67 local offset
= Text
.offset(line
.data
, pos
)
68 if i
>= #line_cache
.screen_line_starting_pos
then
69 return line
.data
:sub(offset
)
71 local endpos
= line_cache
.screen_line_starting_pos
[i
+1]-1
72 local end_offset
= Text
.offset(line
.data
, endpos
)
73 return line
.data
:sub(offset
, end_offset
)
76 function Text
.draw_cursor(State
, x
, y
)
78 if math
.floor(Cursor_time
*2)%2 == 0 then
79 App
.color(Cursor_color
)
80 love
.graphics
.rectangle('fill', x
,y
, 3,State
.line_height
)
83 State
.cursor_y
= y
+State
.line_height
86 function Text
.populate_screen_line_starting_pos(State
, line_index
)
87 local line
= State
.lines
[line_index
]
88 if line
.mode
~= 'text' then return end
89 local line_cache
= State
.line_cache
[line_index
]
90 if line_cache
.screen_line_starting_pos
then
93 line_cache
.screen_line_starting_pos
= {1}
96 -- try to wrap at word boundaries
97 for frag
in line
.data
:gmatch('%S*%s*') do
98 local frag_width
= State
.font
:getWidth(frag
)
99 --? print('-- frag:', frag, pos, x, frag_width, State.width)
100 while x
+ frag_width
> State
.width
do
101 --? print('frag:', frag, pos, x, frag_width, State.width)
102 if x
< 0.8 * State
.width
then
103 -- long word; chop it at some letter
104 -- We're not going to reimplement TeX here.
105 local bpos
= Text
.nearest_pos_less_than(State
.font
, frag
, State
.width
- x
)
106 if x
== 0 and bpos
== 0 then
107 assert(false, ("Infinite loop while line-wrapping. Editor is %dpx wide; window is %dpx wide"):format(State
.width
, App
.screen
.width
))
110 local boffset
= Text
.offset(frag
, bpos
+1) -- byte _after_ bpos
111 frag
= string.sub(frag
, boffset
)
113 --? print('after chop:', frag)
115 frag_width
= State
.font
:getWidth(frag
)
117 --? print('screen line:', pos)
118 table.insert(line_cache
.screen_line_starting_pos
, pos
)
119 x
= 0 -- new screen line
122 pos
= pos
+ utf8
.len(frag
)
126 function Text
.text_input(State
, t
)
127 if App
.mouse_down(1) then return end
128 if App
.any_modifier_down() then
129 if App
.key_down(t
) then
130 -- The modifiers didn't change the key. Handle it in keychord_pressed.
133 -- Key mutated by the keyboard layout. Continue below.
136 local before
= snapshot(State
, State
.cursor1
.line
)
137 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
138 Text
.insert_at_cursor(State
, t
)
139 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
140 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
141 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
143 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
146 function Text
.insert_at_cursor(State
, t
)
147 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
148 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
149 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
)
150 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
151 State
.cursor1
.pos
= State
.cursor1
.pos
+1
154 -- Don't handle any keys here that would trigger text_input above.
155 function Text
.keychord_press(State
, chord
)
156 --? print('chord', chord, State.selection1.line, State.selection1.pos)
157 --== shortcuts that mutate text
158 if chord
== 'return' then
159 local before_line
= State
.cursor1
.line
160 local before
= snapshot(State
, before_line
)
161 Text
.insert_return(State
)
162 State
.selection1
= {}
163 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
164 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
167 record_undo_event(State
, {before
=before
, after
=snapshot(State
, before_line
, State
.cursor1
.line
)})
168 elseif chord
== 'tab' then
169 local before
= snapshot(State
, State
.cursor1
.line
)
170 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
171 Text
.insert_at_cursor(State
, '\t')
172 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
173 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
174 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
175 --? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
178 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
179 elseif chord
== 'backspace' then
180 if State
.selection1
.line
then
181 Text
.delete_selection(State
, State
.left
, State
.right
)
186 if State
.cursor1
.pos
> 1 then
187 before
= snapshot(State
, State
.cursor1
.line
)
188 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1)
189 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
192 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
)
194 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
196 State
.cursor1
.pos
= State
.cursor1
.pos
-1
198 elseif State
.cursor1
.line
> 1 then
199 before
= snapshot(State
, State
.cursor1
.line
-1, State
.cursor1
.line
)
200 if State
.lines
[State
.cursor1
.line
-1].mode
== 'drawing' then
201 table.remove(State
.lines
, State
.cursor1
.line
-1)
202 table.remove(State
.line_cache
, State
.cursor1
.line
-1)
205 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
-1].data
)+1
206 State
.lines
[State
.cursor1
.line
-1].data
= State
.lines
[State
.cursor1
.line
-1].data
..State
.lines
[State
.cursor1
.line
].data
207 table.remove(State
.lines
, State
.cursor1
.line
)
208 table.remove(State
.line_cache
, State
.cursor1
.line
)
210 State
.cursor1
.line
= State
.cursor1
.line
-1
212 if State
.screen_top1
.line
> #State
.lines
then
213 Text
.populate_screen_line_starting_pos(State
, #State
.lines
)
214 local line_cache
= State
.line_cache
[#State
.line_cache
]
215 State
.screen_top1
= {line
=#State
.lines
, pos
=line_cache
.screen_line_starting_pos
[#line_cache
.screen_line_starting_pos
]}
216 elseif Text
.lt1(State
.cursor1
, State
.screen_top1
) then
217 State
.screen_top1
= {
218 line
=State
.cursor1
.line
,
219 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
221 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
223 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
224 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
))
226 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
227 elseif chord
== 'delete' then
228 if State
.selection1
.line
then
229 Text
.delete_selection(State
, State
.left
, State
.right
)
234 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
235 before
= snapshot(State
, State
.cursor1
.line
)
237 before
= snapshot(State
, State
.cursor1
.line
, State
.cursor1
.line
+1)
239 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
240 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
241 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
+1)
244 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
)
246 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
248 -- no change to State.cursor1.pos
250 elseif State
.cursor1
.line
< #State
.lines
then
251 if State
.lines
[State
.cursor1
.line
+1].mode
== 'text' then
253 State
.lines
[State
.cursor1
.line
].data
= State
.lines
[State
.cursor1
.line
].data
..State
.lines
[State
.cursor1
.line
+1].data
255 table.remove(State
.lines
, State
.cursor1
.line
+1)
256 table.remove(State
.line_cache
, State
.cursor1
.line
+1)
258 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
260 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
261 --== shortcuts that move the cursor
262 elseif chord
== 'left' then
264 State
.selection1
= {}
265 elseif chord
== 'right' then
267 State
.selection1
= {}
268 elseif chord
== 'S-left' then
269 if State
.selection1
.line
== nil then
270 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
273 elseif chord
== 'S-right' then
274 if State
.selection1
.line
== nil then
275 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
278 -- C- hotkeys reserved for drawings, so we'll use M-
279 elseif chord
== 'M-left' then
280 Text
.word_left(State
)
281 State
.selection1
= {}
282 elseif chord
== 'M-right' then
283 Text
.word_right(State
)
284 State
.selection1
= {}
285 elseif chord
== 'M-S-left' then
286 if State
.selection1
.line
== nil then
287 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
289 Text
.word_left(State
)
290 elseif chord
== 'M-S-right' then
291 if State
.selection1
.line
== nil then
292 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
294 Text
.word_right(State
)
295 elseif chord
== 'home' then
296 Text
.start_of_line(State
)
297 State
.selection1
= {}
298 elseif chord
== 'end' then
299 Text
.end_of_line(State
)
300 State
.selection1
= {}
301 elseif chord
== 'S-home' then
302 if State
.selection1
.line
== nil then
303 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
305 Text
.start_of_line(State
)
306 elseif chord
== 'S-end' then
307 if State
.selection1
.line
== nil then
308 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
310 Text
.end_of_line(State
)
311 elseif chord
== 'up' then
313 State
.selection1
= {}
314 elseif chord
== 'down' then
316 State
.selection1
= {}
317 elseif chord
== 'S-up' then
318 if State
.selection1
.line
== nil then
319 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
322 elseif chord
== 'S-down' then
323 if State
.selection1
.line
== nil then
324 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
327 elseif chord
== 'pageup' then
329 State
.selection1
= {}
330 elseif chord
== 'pagedown' then
332 State
.selection1
= {}
333 elseif chord
== 'S-pageup' then
334 if State
.selection1
.line
== nil then
335 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
338 elseif chord
== 'S-pagedown' then
339 if State
.selection1
.line
== nil then
340 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
346 function Text
.insert_return(State
)
347 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
348 table.insert(State
.lines
, State
.cursor1
.line
+1, {mode
='text', data
=string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_offset
)})
349 table.insert(State
.line_cache
, State
.cursor1
.line
+1, {})
350 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_offset
-1)
351 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
352 State
.cursor1
= {line
=State
.cursor1
.line
+1, pos
=1}
355 function Text
.pageup(State
)
357 -- duplicate some logic from love.draw
358 local top2
= Text
.to2(State
, State
.screen_top1
)
359 --? print(App.screen.height)
360 local y
= App
.screen
.height
- State
.line_height
361 while y
>= State
.top
do
362 --? print(y, top2.line, top2.screen_line, top2.screen_pos)
363 if State
.screen_top1
.line
== 1 and State
.screen_top1
.pos
== 1 then break end
364 if State
.lines
[State
.screen_top1
.line
].mode
== 'text' then
365 y
= y
- State
.line_height
366 elseif State
.lines
[State
.screen_top1
.line
].mode
== 'drawing' then
367 y
= y
- Drawing_padding_height
- Drawing
.pixels(State
.lines
[State
.screen_top1
.line
].h
, State
.width
)
369 top2
= Text
.previous_screen_line(State
, top2
)
371 State
.screen_top1
= Text
.to1(State
, top2
)
372 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
373 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
374 --? print(State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
375 --? print('pageup end')
378 function Text
.pagedown(State
)
379 --? print('pagedown')
380 -- If a line/paragraph gets to a page boundary, I often want to scroll
381 -- before I get to the bottom.
382 -- However, only do this if it makes forward progress.
383 local bot2
= Text
.to2(State
, State
.screen_bottom1
)
384 if bot2
.screen_line
> 1 then
385 bot2
.screen_line
= math
.max(bot2
.screen_line
-10, 1)
387 local new_top1
= Text
.to1(State
, bot2
)
388 if Text
.lt1(State
.screen_top1
, new_top1
) then
389 State
.screen_top1
= new_top1
391 State
.screen_top1
= {line
=State
.screen_bottom1
.line
, pos
=State
.screen_bottom1
.pos
}
393 --? print('setting top to', State.screen_top1.line, State.screen_top1.pos)
394 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
395 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
396 --? print('top now', State.screen_top1.line)
397 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
398 --? print('pagedown end')
401 function Text
.up(State
)
402 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
403 --? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
404 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
405 if screen_line_starting_pos
== 1 then
406 --? print('cursor is at first screen line of its line')
407 -- line is done; skip to previous text line
408 local new_cursor_line
= State
.cursor1
.line
409 while new_cursor_line
> 1 do
410 new_cursor_line
= new_cursor_line
-1
411 if State
.lines
[new_cursor_line
].mode
== 'text' then
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
427 -- move up one screen line in current line
428 assert(screen_line_index
> 1, 'bumped up against top screen line in line')
429 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
-1]
430 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
431 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
432 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
433 --? print('cursor pos is now '..tostring(State.cursor1.pos))
435 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
436 State
.screen_top1
= {
437 line
=State
.cursor1
.line
,
438 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
440 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
444 function Text
.down(State
)
445 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
446 --? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
447 assert(State
.cursor1
.pos
, 'cursor has no pos')
448 if Text
.cursor_at_final_screen_line(State
) then
449 -- line is done, skip to next text line
450 --? print('cursor at final screen line of its line')
451 local new_cursor_line
= State
.cursor1
.line
452 while new_cursor_line
< #State
.lines
do
453 new_cursor_line
= new_cursor_line
+1
454 if State
.lines
[new_cursor_line
].mode
== 'text' then
456 line
= new_cursor_line
,
457 pos
= Text
.nearest_cursor_pos(State
.font
, State
.lines
[new_cursor_line
].data
, State
.cursor_x
, State
.left
),
459 --? print(State.cursor1.pos)
463 if State
.cursor1
.line
> State
.screen_bottom1
.line
then
464 --? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
465 --? print('scroll up preserving cursor')
466 Text
.snap_cursor_to_bottom_of_screen(State
)
467 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
470 -- move down one screen line in current line
471 local scroll_down
= Text
.le1(State
.screen_bottom1
, State
.cursor1
)
472 --? print('cursor is NOT at final screen line of its line')
473 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
474 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
475 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
+1]
476 --? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
477 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
478 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
479 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, State
.cursor_x
, State
.left
) - 1
480 --? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
482 --? print('scroll up preserving cursor')
483 Text
.snap_cursor_to_bottom_of_screen(State
)
484 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
487 --? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
490 function Text
.start_of_line(State
)
491 State
.cursor1
.pos
= 1
492 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
493 State
.screen_top1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
} -- copy
497 function Text
.end_of_line(State
)
498 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) + 1
499 if Text
.cursor_out_of_screen(State
) then
500 Text
.snap_cursor_to_bottom_of_screen(State
)
504 function Text
.word_left(State
)
505 -- skip some whitespace
507 if State
.cursor1
.pos
== 1 then
510 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%S') then
515 -- skip some non-whitespace
518 if State
.cursor1
.pos
== 1 then
521 assert(State
.cursor1
.pos
> 1, 'bumped up against start of line')
522 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%s') then
528 function Text
.word_right(State
)
529 -- skip some whitespace
531 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
534 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%S') then
537 Text
.right_without_scroll(State
)
540 Text
.right_without_scroll(State
)
541 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
544 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%s') then
548 if Text
.cursor_out_of_screen(State
) then
549 Text
.snap_cursor_to_bottom_of_screen(State
)
553 function Text
.match(s
, pos
, pat
)
554 local start_offset
= Text
.offset(s
, pos
)
555 local end_offset
= Text
.offset(s
, pos
+1)
556 assert(end_offset
> start_offset
, ('end_offset %d not > start_offset %d'):format(end_offset
, start_offset
))
557 local curr
= s
:sub(start_offset
, end_offset
-1)
558 return curr
:match(pat
)
561 function Text
.left(State
)
562 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
563 if State
.cursor1
.pos
> 1 then
564 State
.cursor1
.pos
= State
.cursor1
.pos
-1
566 local new_cursor_line
= State
.cursor1
.line
567 while new_cursor_line
> 1 do
568 new_cursor_line
= new_cursor_line
-1
569 if State
.lines
[new_cursor_line
].mode
== 'text' then
571 line
= new_cursor_line
,
572 pos
= utf8
.len(State
.lines
[new_cursor_line
].data
) + 1,
578 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
579 State
.screen_top1
= {
580 line
=State
.cursor1
.line
,
581 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
583 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
587 function Text
.right(State
)
588 Text
.right_without_scroll(State
)
589 if Text
.cursor_out_of_screen(State
) then
590 Text
.snap_cursor_to_bottom_of_screen(State
)
594 function Text
.right_without_scroll(State
)
595 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
596 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
597 State
.cursor1
.pos
= State
.cursor1
.pos
+1
599 local new_cursor_line
= State
.cursor1
.line
600 while new_cursor_line
<= #State
.lines
-1 do
601 new_cursor_line
= new_cursor_line
+1
602 if State
.lines
[new_cursor_line
].mode
== 'text' then
603 State
.cursor1
= {line
=new_cursor_line
, pos
=1}
610 -- result: pos, index of screen line
611 function Text
.pos_at_start_of_screen_line(State
, loc1
)
612 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
613 local line_cache
= State
.line_cache
[loc1
.line
]
614 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
615 local spos
= line_cache
.screen_line_starting_pos
[i
]
616 if spos
<= loc1
.pos
then
620 assert(false, ('invalid pos %d'):format(loc1
.pos
))
623 function Text
.pos_at_end_of_screen_line(State
, loc1
)
624 assert(State
.lines
[loc1
.line
].mode
== 'text')
625 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
626 local line_cache
= State
.line_cache
[loc1
.line
]
627 local most_recent_final_pos
= utf8
.len(State
.lines
[loc1
.line
].data
)+1
628 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
629 local spos
= line_cache
.screen_line_starting_pos
[i
]
630 if spos
<= loc1
.pos
then
631 return most_recent_final_pos
633 most_recent_final_pos
= spos
-1
635 assert(false, ('invalid pos %d'):format(loc1
.pos
))
638 function Text
.final_text_loc_on_screen(State
)
639 if State
.lines
[State
.screen_bottom1
.line
].mode
== 'text' then
641 line
=State
.screen_bottom1
.line
,
642 pos
=Text
.pos_at_end_of_screen_line(State
, State
.screen_bottom1
),
645 local loc2
= Text
.to2(State
, State
.screen_bottom1
)
647 if State
.lines
[loc2
.line
].mode
== 'text' then break end
648 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
649 loc2
= Text
.previous_screen_line(State
, loc2
)
651 local result
= Text
.to1(State
, loc2
)
652 result
.pos
= Text
.pos_at_end_of_screen_line(State
, result
)
656 function Text
.cursor_at_final_screen_line(State
)
657 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
658 local screen_lines
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
659 --? print(screen_lines[#screen_lines], State.cursor1.pos)
660 return screen_lines
[#screen_lines
] <= State
.cursor1
.pos
663 function Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
665 while State
.cursor1
.line
<= #State
.lines
do
666 if State
.lines
[State
.cursor1
.line
].mode
== 'text' then
669 --? print('cursor skips', State.cursor1.line)
670 y
= y
+ Drawing_padding_height
+ Drawing
.pixels(State
.lines
[State
.cursor1
.line
].h
, State
.width
)
671 State
.cursor1
.line
= State
.cursor1
.line
+ 1
673 if State
.cursor1
.pos
== nil then
674 State
.cursor1
.pos
= 1
676 -- hack: insert a text line at bottom of file if necessary
677 if State
.cursor1
.line
> #State
.lines
then
678 assert(State
.cursor1
.line
== #State
.lines
+1, 'tried to ensure bottom line of file is text, but failed')
679 table.insert(State
.lines
, {mode
='text', data
=''})
680 table.insert(State
.line_cache
, {})
682 --? print(y, App.screen.height, App.screen.height-State.line_height)
683 if y
> App
.screen
.height
- State
.line_height
then
684 --? print('scroll up')
685 Text
.snap_cursor_to_bottom_of_screen(State
)
689 -- should never modify State.cursor1
690 function Text
.snap_cursor_to_bottom_of_screen(State
)
691 --? print('to2:', State.cursor1.line, State.cursor1.pos)
692 local top2
= Text
.to2(State
, State
.cursor1
)
693 --? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
694 -- slide to start of screen line
695 top2
.screen_pos
= 1 -- start of screen line
696 --? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
697 --? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
698 local y
= App
.screen
.height
- State
.line_height
699 -- duplicate some logic from love.draw
701 --? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
702 if top2
.line
== 1 and top2
.screen_line
== 1 then break end
703 if top2
.screen_line
> 1 or State
.lines
[top2
.line
-1].mode
== 'text' then
704 local h
= State
.line_height
705 if y
- h
< State
.top
then
710 assert(top2
.line
> 1, 'tried to snap cursor to buttom of screen but failed')
711 assert(State
.lines
[top2
.line
-1].mode
== 'drawing', "expected a drawing but it's not")
712 -- We currently can't draw partial drawings, so either skip it entirely
714 local h
= Drawing_padding_height
+ Drawing
.pixels(State
.lines
[top2
.line
-1].h
, State
.width
)
715 if y
- h
< State
.top
then
718 --? print('skipping drawing of height', h)
721 top2
= Text
.previous_screen_line(State
, top2
)
723 --? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
724 State
.screen_top1
= Text
.to1(State
, top2
)
725 --? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
726 --? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
727 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
730 function Text
.in_line(State
, line_index
, x
,y
)
731 local line
= State
.lines
[line_index
]
732 local line_cache
= State
.line_cache
[line_index
]
733 if line_cache
.starty
== nil then return false end -- outside current page
734 if y
< line_cache
.starty
then return false end
735 Text
.populate_screen_line_starting_pos(State
, line_index
)
736 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)
739 -- convert mx,my in pixels to schema-1 coordinates
740 function Text
.to_pos_on_line(State
, line_index
, mx
, my
)
741 local line
= State
.lines
[line_index
]
742 local line_cache
= State
.line_cache
[line_index
]
743 assert(my
>= line_cache
.starty
, 'failed to map y pixel to line')
744 -- duplicate some logic from Text.draw
745 local y
= line_cache
.starty
746 local start_screen_line_index
= Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
)
747 for screen_line_index
= start_screen_line_index
,#line_cache
.screen_line_starting_pos
do
748 local screen_line_starting_pos
= line_cache
.screen_line_starting_pos
[screen_line_index
]
749 local screen_line_starting_byte_offset
= Text
.offset(line
.data
, screen_line_starting_pos
)
750 --? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
751 local nexty
= y
+ State
.line_height
753 -- On all wrapped screen lines but the final one, clicks past end of
754 -- line position cursor on final character of screen line.
755 -- (The final screen line positions past end of screen line as always.)
756 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
757 --? print('past end of non-final line; return')
758 return line_cache
.screen_line_starting_pos
[screen_line_index
+1]
760 local s
= string.sub(line
.data
, screen_line_starting_byte_offset
)
761 --? 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)
762 return screen_line_starting_pos
+ Text
.nearest_cursor_pos(State
.font
, s
, mx
, State
.left
) - 1
766 assert(false, 'failed to map y pixel to line')
769 function Text
.screen_line_width(State
, line_index
, i
)
770 local line
= State
.lines
[line_index
]
771 local line_cache
= State
.line_cache
[line_index
]
772 local start_pos
= line_cache
.screen_line_starting_pos
[i
]
773 local start_offset
= Text
.offset(line
.data
, start_pos
)
775 if i
< #line_cache
.screen_line_starting_pos
then
776 local past_end_pos
= line_cache
.screen_line_starting_pos
[i
+1]
777 local past_end_offset
= Text
.offset(line
.data
, past_end_pos
)
778 screen_line
= string.sub(line
.data
, start_offset
, past_end_offset
-1)
780 screen_line
= string.sub(line
.data
, start_pos
)
782 return State
.font
:getWidth(screen_line
)
785 function Text
.screen_line_index(screen_line_starting_pos
, pos
)
786 for i
= #screen_line_starting_pos
,1,-1 do
787 if screen_line_starting_pos
[i
] <= pos
then
793 -- convert x pixel coordinate to pos
794 -- oblivious to wrapping
795 -- result: 1 to len+1
796 function Text
.nearest_cursor_pos(font
, line
, x
, left
)
800 local len
= utf8
.len(line
)
801 local max_x
= left
+Text
.x(font
, line
, len
+1)
805 local leftpos
, rightpos
= 1, len
+1
806 --? print('-- nearest', x)
808 --? print('nearest', x, '^'..line..'$', leftpos, rightpos)
809 if leftpos
== rightpos
then
812 local curr
= math
.floor((leftpos
+rightpos
)/2)
813 local currxmin
= left
+Text
.x(font
, line
, curr
)
814 local currxmax
= left
+Text
.x(font
, line
, curr
+1)
815 --? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
816 if currxmin
<= x
and x
< currxmax
then
817 if x
-currxmin
< currxmax
-x
then
823 if leftpos
>= rightpos
-1 then
832 assert(false, 'failed to map x pixel to pos')
835 -- return the nearest index of line (in utf8 code points) which lies entirely
836 -- within x pixels of the left margin
837 -- result: 0 to len+1
838 function Text
.nearest_pos_less_than(font
, line
, x
)
839 --? print('', '-- nearest_pos_less_than', line, x)
840 local len
= utf8
.len(line
)
841 local max_x
= Text
.x_after(font
, line
, len
)
845 local left
, right
= 0, len
+1
847 local curr
= math
.floor((left
+right
)/2)
848 local currxmin
= Text
.x_after(font
, line
, curr
+1)
849 local currxmax
= Text
.x_after(font
, line
, curr
+2)
850 --? print('', x, left, right, curr, currxmin, currxmax)
851 if currxmin
<= x
and x
< currxmax
then
854 if left
>= right
-1 then
863 assert(false, 'failed to map x pixel to pos')
866 function Text
.x_after(font
, s
, pos
)
867 local len
= utf8
.len(s
)
868 local offset
= Text
.offset(s
, math
.min(pos
+1, len
+1))
869 local s_before
= s
:sub(1, offset
-1)
870 --? print('^'..s_before..'$')
871 return font
:getWidth(s_before
)
874 function Text
.x(font
, s
, pos
)
875 local offset
= Text
.offset(s
, pos
)
876 local s_before
= s
:sub(1, offset
-1)
877 return font
:getWidth(s_before
)
880 function Text
.to2(State
, loc1
)
881 if State
.lines
[loc1
.line
].mode
== 'drawing' then
882 return {line
=loc1
.line
, screen_line
=1, screen_pos
=1}
884 local result
= {line
=loc1
.line
}
885 local line_cache
= State
.line_cache
[loc1
.line
]
886 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
887 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
888 local spos
= line_cache
.screen_line_starting_pos
[i
]
889 if spos
<= loc1
.pos
then
890 result
.screen_line
= i
891 result
.screen_pos
= loc1
.pos
- spos
+ 1
895 assert(result
.screen_pos
, 'failed to convert schema-1 coordinate to schema-2')
899 function Text
.to1(State
, loc2
)
900 local result
= {line
=loc2
.line
, pos
=loc2
.screen_pos
}
901 if loc2
.screen_line
> 1 then
902 result
.pos
= State
.line_cache
[loc2
.line
].screen_line_starting_pos
[loc2
.screen_line
] + loc2
.screen_pos
- 1
907 function Text
.eq1(a
, b
)
908 return a
.line
== b
.line
and a
.pos
== b
.pos
911 function Text
.lt1(a
, b
)
912 if a
.line
< b
.line
then
915 if a
.line
> b
.line
then
921 function Text
.le1(a
, b
)
922 if a
.line
< b
.line
then
925 if a
.line
> b
.line
then
928 return a
.pos
<= b
.pos
931 function Text
.offset(s
, pos1
)
932 if pos1
== 1 then return 1 end
933 local result
= utf8
.offset(s
, pos1
)
934 if result
== nil then
935 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
))
940 function Text
.previous_screen_line(State
, loc2
)
941 if loc2
.screen_line
> 1 then
942 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
-1, screen_pos
=1}
943 elseif loc2
.line
== 1 then
945 elseif State
.lines
[loc2
.line
-1].mode
== 'drawing' then
946 return {line
=loc2
.line
-1, screen_line
=1, screen_pos
=1}
948 local l
= State
.lines
[loc2
.line
-1]
949 Text
.populate_screen_line_starting_pos(State
, loc2
.line
-1)
950 return {line
=loc2
.line
-1, screen_line
=#State
.line_cache
[loc2
.line
-1].screen_line_starting_pos
, screen_pos
=1}
955 function Text
.tweak_screen_top_and_cursor(State
)
956 if State
.screen_top1
.pos
== 1 then return end
957 Text
.populate_screen_line_starting_pos(State
, State
.screen_top1
.line
)
958 local line
= State
.lines
[State
.screen_top1
.line
]
959 local line_cache
= State
.line_cache
[State
.screen_top1
.line
]
960 for i
=2,#line_cache
.screen_line_starting_pos
do
961 local pos
= line_cache
.screen_line_starting_pos
[i
]
962 if pos
== State
.screen_top1
.pos
then
965 if pos
> State
.screen_top1
.pos
then
966 -- make sure screen top is at start of a screen line
967 local prev
= line_cache
.screen_line_starting_pos
[i
-1]
968 if State
.screen_top1
.pos
- prev
< pos
- State
.screen_top1
.pos
then
969 State
.screen_top1
.pos
= prev
971 State
.screen_top1
.pos
= pos
976 -- make sure cursor is on screen
977 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
978 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
979 elseif State
.cursor1
.line
>= State
.screen_bottom1
.line
then
981 if Text
.cursor_out_of_screen(State
) then
984 line
=State
.screen_bottom1
.line
,
985 pos
=Text
.to_pos_on_line(State
, State
.screen_bottom1
.line
, State
.right
-5, App
.screen
.height
-5),
991 -- slightly expensive since it redraws the screen
992 function Text
.cursor_out_of_screen(State
)
994 return State
.cursor_y
== nil
995 -- this approach is cheaper and almost works, except on the final screen
996 -- where file ends above bottom of screen
997 --? local botpos = Text.pos_at_start_of_screen_line(State, State.cursor1)
998 --? local botline1 = {line=State.cursor1.line, pos=botpos}
999 --? return Text.lt1(State.screen_bottom1, botline1)
1002 function Text
.redraw_all(State
)
1003 --? print('clearing fragments')
1004 -- Perform some early sanity checking here, in hopes that we correctly call
1005 -- this whenever we change editor state.
1006 if State
.right
<= State
.left
then
1007 assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State
.right
, State
.left
))
1010 State
.line_cache
= {}
1011 for i
=1,#State
.lines
do
1012 State
.line_cache
[i
] = {}
1016 function Text
.clear_screen_line_cache(State
, line_index
)
1017 State
.line_cache
[line_index
].screen_line_starting_pos
= nil
1021 return s
:gsub('^%s+', ''):gsub('%s+$', '')
1025 return s
:gsub('^%s+', '')
1029 return s
:gsub('%s+$', '')
1032 function starts_with(s
, prefix
)
1033 if #s
< #prefix
then
1037 if s
:sub(i
,i
) ~= prefix
:sub(i
,i
) then
1044 function ends_with(s
, suffix
)
1045 if #s
< #suffix
then
1048 for i
=0,#suffix
-1 do
1049 if s
:sub(#s
-i
,#s
-i
) ~= suffix
:sub(#suffix
-i
,#suffix
-i
) then