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
, hide_cursor
, show_line_numbers
)
7 local line
= State
.lines
[line_index
]
8 local line_cache
= State
.line_cache
[line_index
]
10 line_cache
.startpos
= startpos
12 local final_screen_line_starting_pos
= startpos
-- track value to return
13 Text
.populate_screen_line_starting_pos(State
, line_index
)
14 Text
.populate_link_offsets(State
, line_index
)
15 if show_line_numbers
then
16 App
.color(Line_number_color
)
17 love
.graphics
.print(line_index
, State
.left
-Line_number_width
*App
.width('m')+10,y
)
20 assert(#line_cache
.screen_line_starting_pos
>= 1, 'line cache missing screen line info')
21 for i
=1,#line_cache
.screen_line_starting_pos
do
22 local pos
= line_cache
.screen_line_starting_pos
[i
]
23 if pos
< startpos
then
25 --? print('skipping', screen_line)
27 final_screen_line_starting_pos
= pos
28 local screen_line
= Text
.screen_line(line
, line_cache
, i
)
29 --? print('text.draw:', screen_line, 'at', line_index,pos, 'after', x,y)
30 local frag_len
= utf8
.len(screen_line
)
31 -- render any highlights
32 for _
,link_offsets
in ipairs(line_cache
.link_offsets
) do
33 -- render link decorations
34 local s
,e
,filename
= unpack(link_offsets
)
35 local lo
, hi
= Text
.clip_wikiword_with_screen_line(line
, line_cache
, i
, s
, e
)
37 button(State
, 'link', {x
=State
.left
+lo
, y
=y
, w
=hi
-lo
, h
=State
.line_height
, bg
={r
=1,g
=1,b
=1},
38 icon
= icon
.hyperlink_decoration
,
40 if file_exists(filename
) then
41 source
.switch_to_file(filename
)
47 if State
.selection1
.line
then
48 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
49 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
51 if not hide_cursor
and line_index
== State
.cursor1
.line
then
52 -- render search highlight or cursor
53 if State
.search_term
then
54 local data
= State
.lines
[State
.cursor1
.line
].data
55 local cursor_offset
= Text
.offset(data
, State
.cursor1
.pos
)
56 if data
:sub(cursor_offset
, cursor_offset
+#State
.search_term
-1) == State
.search_term
then
57 local save_selection
= State
.selection1
58 State
.selection1
= {line
=line_index
, pos
=State
.cursor1
.pos
+utf8
.len(State
.search_term
)}
59 local lo
, hi
= Text
.clip_selection(State
, line_index
, pos
, pos
+frag_len
)
60 Text
.draw_highlight(State
, line
, State
.left
,y
, pos
, lo
,hi
)
61 State
.selection1
= save_selection
63 elseif Focus
== 'edit' then
64 if pos
<= State
.cursor1
.pos
and pos
+ frag_len
> State
.cursor1
.pos
then
65 Text
.draw_cursor(State
, State
.left
+Text
.x(screen_line
, State
.cursor1
.pos
-pos
+1), y
)
66 elseif pos
+ frag_len
== State
.cursor1
.pos
then
67 -- Show cursor at end of line.
68 -- This place also catches end of wrapping screen lines. That doesn't seem worth distinguishing.
69 -- It seems useful to see a cursor whether your eye is on the left or right margin.
70 Text
.draw_cursor(State
, State
.left
+Text
.x(screen_line
, State
.cursor1
.pos
-pos
+1), y
)
74 -- render colorized text
76 for frag
in screen_line
:gmatch('%S*%s*') do
78 App
.screen
.print(frag
, x
,y
)
81 y
= y
+ State
.line_height
82 if y
>= App
.screen
.height
then
87 return y
, final_screen_line_starting_pos
90 function Text
.screen_line(line
, line_cache
, i
)
91 local pos
= line_cache
.screen_line_starting_pos
[i
]
92 local offset
= Text
.offset(line
.data
, pos
)
93 if i
>= #line_cache
.screen_line_starting_pos
then
94 return line
.data
:sub(offset
)
96 local endpos
= line_cache
.screen_line_starting_pos
[i
+1]-1
97 local end_offset
= Text
.offset(line
.data
, endpos
)
98 return line
.data
:sub(offset
, end_offset
)
101 function Text
.draw_cursor(State
, x
, y
)
103 if math
.floor(Cursor_time
*2)%2 == 0 then
104 App
.color(Cursor_color
)
105 love
.graphics
.rectangle('fill', x
,y
, 3,State
.line_height
)
108 State
.cursor_y
= y
+State
.line_height
111 function Text
.populate_screen_line_starting_pos(State
, line_index
)
112 local line
= State
.lines
[line_index
]
113 if line
.mode
~= 'text' then return end
114 local line_cache
= State
.line_cache
[line_index
]
115 if line_cache
.screen_line_starting_pos
then
118 line_cache
.screen_line_starting_pos
= {1}
121 -- try to wrap at word boundaries
122 for frag
in line
.data
:gmatch('%S*%s*') do
123 local frag_width
= App
.width(frag
)
124 --? print('-- frag:', frag, pos, x, frag_width, State.width)
125 while x
+ frag_width
> State
.width
do
126 --? print('frag:', frag, pos, x, frag_width, State.width)
127 if x
< 0.8 * State
.width
then
128 -- long word; chop it at some letter
129 -- We're not going to reimplement TeX here.
130 local bpos
= Text
.nearest_pos_less_than(frag
, State
.width
- x
)
131 if x
== 0 and bpos
== 0 then
132 assert(false, ("Infinite loop while line-wrapping. Editor is %dpx wide; window is %dpx wide"):format(State
.width
, App
.screen
.width
))
135 local boffset
= Text
.offset(frag
, bpos
+1) -- byte _after_ bpos
136 frag
= string.sub(frag
, boffset
)
138 --? print('after chop:', frag)
140 frag_width
= App
.width(frag
)
142 --? print('screen line:', pos)
143 table.insert(line_cache
.screen_line_starting_pos
, pos
)
144 x
= 0 -- new screen line
147 pos
= pos
+ utf8
.len(frag
)
151 function Text
.populate_link_offsets(State
, line_index
)
152 local line
= State
.lines
[line_index
]
153 if line
.mode
~= 'text' then return end
154 local line_cache
= State
.line_cache
[line_index
]
155 if line_cache
.link_offsets
then
158 line_cache
.link_offsets
= {}
160 -- try to wrap at word boundaries
162 while s
<= #line
.data
do
163 s
, e
= line
.data
:find('%[%[%S+%]%]', s
)
164 if s
== nil then break end
165 local word
= line
.data
:sub(s
+2, e
-2) -- strip out surrounding '[[..]]'
166 --? print('wikiword:', s, e, word)
167 table.insert(line_cache
.link_offsets
, {s
, e
, word
})
172 -- Intersect the filename between byte offsets s,e with the bounds of screen line i.
173 -- Return the left/right pixel coordinates of of the intersection,
174 -- or nil if it doesn't intersect with screen line i.
175 function Text
.clip_wikiword_with_screen_line(line
, line_cache
, i
, s
, e
)
176 local spos
= line_cache
.screen_line_starting_pos
[i
]
177 local soff
= Text
.offset(line
.data
, spos
)
182 if i
< #line_cache
.screen_line_starting_pos
then
183 local epos
= line_cache
.screen_line_starting_pos
[i
+1]
184 eoff
= Text
.offset(line
.data
, epos
)
189 local loff
= math
.max(s
, soff
)
192 hoff
= math
.min(e
, eoff
)
196 --? print(s, e, soff, eoff, loff, hoff)
197 return App
.width(line
.data
:sub(soff
, loff
-1)), App
.width(line
.data
:sub(soff
, hoff
))
200 function Text
.text_input(State
, t
)
201 if App
.mouse_down(1) then return end
202 if App
.any_modifier_down() then
203 if App
.key_down(t
) then
204 -- The modifiers didn't change the key. Handle it in keychord_pressed.
207 -- Key mutated by the keyboard layout. Continue below.
210 local before
= snapshot(State
, State
.cursor1
.line
)
211 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
212 Text
.insert_at_cursor(State
, t
)
213 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
214 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
215 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
217 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
220 function Text
.insert_at_cursor(State
, t
)
221 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
222 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
223 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
)
224 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
225 State
.cursor1
.pos
= State
.cursor1
.pos
+1
228 -- Don't handle any keys here that would trigger text_input above.
229 function Text
.keychord_press(State
, chord
)
230 --? print('chord', chord, State.selection1.line, State.selection1.pos)
231 --== shortcuts that mutate text
232 if chord
== 'return' then
233 local before_line
= State
.cursor1
.line
234 local before
= snapshot(State
, before_line
)
235 Text
.insert_return(State
)
236 State
.selection1
= {}
237 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
238 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
241 record_undo_event(State
, {before
=before
, after
=snapshot(State
, before_line
, State
.cursor1
.line
)})
242 elseif chord
== 'tab' then
243 local before
= snapshot(State
, State
.cursor1
.line
)
244 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
245 Text
.insert_at_cursor(State
, '\t')
246 if State
.cursor_y
> App
.screen
.height
- State
.line_height
then
247 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
248 Text
.snap_cursor_to_bottom_of_screen(State
, State
.left
, State
.right
)
249 --? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
252 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
253 elseif chord
== 'backspace' then
254 if State
.selection1
.line
then
255 Text
.delete_selection(State
, State
.left
, State
.right
)
260 if State
.cursor1
.pos
> 1 then
261 before
= snapshot(State
, State
.cursor1
.line
)
262 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1)
263 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
266 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
)
268 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
270 State
.cursor1
.pos
= State
.cursor1
.pos
-1
272 elseif State
.cursor1
.line
> 1 then
273 before
= snapshot(State
, State
.cursor1
.line
-1, State
.cursor1
.line
)
274 if State
.lines
[State
.cursor1
.line
-1].mode
== 'drawing' then
275 table.remove(State
.lines
, State
.cursor1
.line
-1)
276 table.remove(State
.line_cache
, State
.cursor1
.line
-1)
279 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
-1].data
)+1
280 State
.lines
[State
.cursor1
.line
-1].data
= State
.lines
[State
.cursor1
.line
-1].data
..State
.lines
[State
.cursor1
.line
].data
281 table.remove(State
.lines
, State
.cursor1
.line
)
282 table.remove(State
.line_cache
, State
.cursor1
.line
)
284 State
.cursor1
.line
= State
.cursor1
.line
-1
286 if State
.screen_top1
.line
> #State
.lines
then
287 Text
.populate_screen_line_starting_pos(State
, #State
.lines
)
288 local line_cache
= State
.line_cache
[#State
.line_cache
]
289 State
.screen_top1
= {line
=#State
.lines
, pos
=line_cache
.screen_line_starting_pos
[#line_cache
.screen_line_starting_pos
]}
290 elseif Text
.lt1(State
.cursor1
, State
.screen_top1
) then
291 State
.screen_top1
= {
292 line
=State
.cursor1
.line
,
293 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
295 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
297 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
298 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
))
300 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
301 elseif chord
== 'delete' then
302 if State
.selection1
.line
then
303 Text
.delete_selection(State
, State
.left
, State
.right
)
308 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
309 before
= snapshot(State
, State
.cursor1
.line
)
311 before
= snapshot(State
, State
.cursor1
.line
, State
.cursor1
.line
+1)
313 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
314 local byte_start
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
315 local byte_end
= utf8
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
+1)
318 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
)
320 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_start
-1)
322 -- no change to State.cursor1.pos
324 elseif State
.cursor1
.line
< #State
.lines
then
325 if State
.lines
[State
.cursor1
.line
+1].mode
== 'text' then
327 State
.lines
[State
.cursor1
.line
].data
= State
.lines
[State
.cursor1
.line
].data
..State
.lines
[State
.cursor1
.line
+1].data
329 table.remove(State
.lines
, State
.cursor1
.line
+1)
330 table.remove(State
.line_cache
, State
.cursor1
.line
+1)
332 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
334 record_undo_event(State
, {before
=before
, after
=snapshot(State
, State
.cursor1
.line
)})
335 --== shortcuts that move the cursor
336 elseif chord
== 'left' then
338 State
.selection1
= {}
339 elseif chord
== 'right' then
341 State
.selection1
= {}
342 elseif chord
== 'S-left' then
343 if State
.selection1
.line
== nil then
344 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
347 elseif chord
== 'S-right' then
348 if State
.selection1
.line
== nil then
349 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
352 -- C- hotkeys reserved for drawings, so we'll use M-
353 elseif chord
== 'M-left' then
354 Text
.word_left(State
)
355 State
.selection1
= {}
356 elseif chord
== 'M-right' then
357 Text
.word_right(State
)
358 State
.selection1
= {}
359 elseif chord
== 'M-S-left' then
360 if State
.selection1
.line
== nil then
361 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
363 Text
.word_left(State
)
364 elseif chord
== 'M-S-right' then
365 if State
.selection1
.line
== nil then
366 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
368 Text
.word_right(State
)
369 elseif chord
== 'home' then
370 Text
.start_of_line(State
)
371 State
.selection1
= {}
372 elseif chord
== 'end' then
373 Text
.end_of_line(State
)
374 State
.selection1
= {}
375 elseif chord
== 'S-home' then
376 if State
.selection1
.line
== nil then
377 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
379 Text
.start_of_line(State
)
380 elseif chord
== 'S-end' then
381 if State
.selection1
.line
== nil then
382 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
384 Text
.end_of_line(State
)
385 elseif chord
== 'up' then
387 State
.selection1
= {}
388 elseif chord
== 'down' then
390 State
.selection1
= {}
391 elseif chord
== 'S-up' then
392 if State
.selection1
.line
== nil then
393 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
396 elseif chord
== 'S-down' then
397 if State
.selection1
.line
== nil then
398 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
401 elseif chord
== 'pageup' then
403 State
.selection1
= {}
404 elseif chord
== 'pagedown' then
406 State
.selection1
= {}
407 elseif chord
== 'S-pageup' then
408 if State
.selection1
.line
== nil then
409 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
412 elseif chord
== 'S-pagedown' then
413 if State
.selection1
.line
== nil then
414 State
.selection1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
}
420 function Text
.insert_return(State
)
421 local byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
)
422 table.insert(State
.lines
, State
.cursor1
.line
+1, {mode
='text', data
=string.sub(State
.lines
[State
.cursor1
.line
].data
, byte_offset
)})
423 table.insert(State
.line_cache
, State
.cursor1
.line
+1, {})
424 State
.lines
[State
.cursor1
.line
].data
= string.sub(State
.lines
[State
.cursor1
.line
].data
, 1, byte_offset
-1)
425 Text
.clear_screen_line_cache(State
, State
.cursor1
.line
)
426 State
.cursor1
= {line
=State
.cursor1
.line
+1, pos
=1}
429 function Text
.pageup(State
)
431 -- duplicate some logic from love.draw
432 local top2
= Text
.to2(State
, State
.screen_top1
)
433 --? print(App.screen.height)
434 local y
= App
.screen
.height
- State
.line_height
435 while y
>= State
.top
do
436 --? print(y, top2.line, top2.screen_line, top2.screen_pos)
437 if State
.screen_top1
.line
== 1 and State
.screen_top1
.pos
== 1 then break end
438 if State
.lines
[State
.screen_top1
.line
].mode
== 'text' then
439 y
= y
- State
.line_height
440 elseif State
.lines
[State
.screen_top1
.line
].mode
== 'drawing' then
441 y
= y
- Drawing_padding_height
- Drawing
.pixels(State
.lines
[State
.screen_top1
.line
].h
, State
.width
)
443 top2
= Text
.previous_screen_line(State
, top2
)
445 State
.screen_top1
= Text
.to1(State
, top2
)
446 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
447 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
448 --? print(State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
449 --? print('pageup end')
452 function Text
.pagedown(State
)
453 --? print('pagedown')
454 State
.screen_top1
= {line
=State
.screen_bottom1
.line
, pos
=State
.screen_bottom1
.pos
}
455 --? print('setting top to', State.screen_top1.line, State.screen_top1.pos)
456 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
457 Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
458 --? print('top now', State.screen_top1.line)
459 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
460 --? print('pagedown end')
463 function Text
.up(State
)
464 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
465 --? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
466 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
467 if screen_line_starting_pos
== 1 then
468 --? print('cursor is at first screen line of its line')
469 -- line is done; skip to previous text line
470 local new_cursor_line
= State
.cursor1
.line
471 while new_cursor_line
> 1 do
472 new_cursor_line
= new_cursor_line
-1
473 if State
.lines
[new_cursor_line
].mode
== 'text' then
474 --? print('found previous text line')
475 State
.cursor1
= {line
=new_cursor_line
, pos
=nil}
476 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
477 -- previous text line found, pick its final screen line
478 --? print('has multiple screen lines')
479 local screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
480 --? print(#screen_line_starting_pos)
481 screen_line_starting_pos
= screen_line_starting_pos
[#screen_line_starting_pos
]
482 local screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_pos
)
483 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, screen_line_starting_byte_offset
)
484 State
.cursor1
.pos
= screen_line_starting_pos
+ Text
.nearest_cursor_pos(s
, State
.cursor_x
, State
.left
) - 1
489 -- move up one screen line in current line
490 assert(screen_line_index
> 1, 'bumped up against top screen line in line')
491 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
-1]
492 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
493 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
494 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(s
, State
.cursor_x
, State
.left
) - 1
495 --? print('cursor pos is now '..tostring(State.cursor1.pos))
497 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
498 State
.screen_top1
= {
499 line
=State
.cursor1
.line
,
500 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
502 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
506 function Text
.down(State
)
507 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
508 --? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
509 assert(State
.cursor1
.pos
, 'cursor has no pos')
510 if Text
.cursor_at_final_screen_line(State
) then
511 -- line is done, skip to next text line
512 --? print('cursor at final screen line of its line')
513 local new_cursor_line
= State
.cursor1
.line
514 while new_cursor_line
< #State
.lines
do
515 new_cursor_line
= new_cursor_line
+1
516 if State
.lines
[new_cursor_line
].mode
== 'text' then
518 line
= new_cursor_line
,
519 pos
= Text
.nearest_cursor_pos(State
.lines
[new_cursor_line
].data
, State
.cursor_x
, State
.left
),
521 --? print(State.cursor1.pos)
525 if State
.cursor1
.line
> State
.screen_bottom1
.line
then
526 --? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
527 --? print('scroll up preserving cursor')
528 Text
.snap_cursor_to_bottom_of_screen(State
)
529 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
532 -- move down one screen line in current line
533 local scroll_down
= Text
.le1(State
.screen_bottom1
, State
.cursor1
)
534 --? print('cursor is NOT at final screen line of its line')
535 local screen_line_starting_pos
, screen_line_index
= Text
.pos_at_start_of_screen_line(State
, State
.cursor1
)
536 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
537 local new_screen_line_starting_pos
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
[screen_line_index
+1]
538 --? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
539 local new_screen_line_starting_byte_offset
= Text
.offset(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_pos
)
540 local s
= string.sub(State
.lines
[State
.cursor1
.line
].data
, new_screen_line_starting_byte_offset
)
541 State
.cursor1
.pos
= new_screen_line_starting_pos
+ Text
.nearest_cursor_pos(s
, State
.cursor_x
, State
.left
) - 1
542 --? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
544 --? print('scroll up preserving cursor')
545 Text
.snap_cursor_to_bottom_of_screen(State
)
546 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
549 --? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
552 function Text
.start_of_line(State
)
553 State
.cursor1
.pos
= 1
554 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
555 State
.screen_top1
= {line
=State
.cursor1
.line
, pos
=State
.cursor1
.pos
} -- copy
559 function Text
.end_of_line(State
)
560 State
.cursor1
.pos
= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) + 1
561 if Text
.cursor_out_of_screen(State
) then
562 Text
.snap_cursor_to_bottom_of_screen(State
)
566 function Text
.word_left(State
)
567 -- skip some whitespace
569 if State
.cursor1
.pos
== 1 then
572 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%S') then
577 -- skip some non-whitespace
580 if State
.cursor1
.pos
== 1 then
583 assert(State
.cursor1
.pos
> 1, 'bumped up against start of line')
584 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
-1, '%s') then
590 function Text
.word_right(State
)
591 -- skip some whitespace
593 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
596 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%S') then
599 Text
.right_without_scroll(State
)
602 Text
.right_without_scroll(State
)
603 if State
.cursor1
.pos
> utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
606 if Text
.match(State
.lines
[State
.cursor1
.line
].data
, State
.cursor1
.pos
, '%s') then
610 if Text
.cursor_out_of_screen(State
) then
611 Text
.snap_cursor_to_bottom_of_screen(State
)
615 function Text
.match(s
, pos
, pat
)
616 local start_offset
= Text
.offset(s
, pos
)
617 local end_offset
= Text
.offset(s
, pos
+1)
618 assert(end_offset
> start_offset
, ('end_offset %d not > start_offset %d'):format(end_offset
, start_offset
))
619 local curr
= s
:sub(start_offset
, end_offset
-1)
620 return curr
:match(pat
)
623 function Text
.left(State
)
624 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
625 if State
.cursor1
.pos
> 1 then
626 State
.cursor1
.pos
= State
.cursor1
.pos
-1
628 local new_cursor_line
= State
.cursor1
.line
629 while new_cursor_line
> 1 do
630 new_cursor_line
= new_cursor_line
-1
631 if State
.lines
[new_cursor_line
].mode
== 'text' then
633 line
= new_cursor_line
,
634 pos
= utf8
.len(State
.lines
[new_cursor_line
].data
) + 1,
640 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
641 State
.screen_top1
= {
642 line
=State
.cursor1
.line
,
643 pos
=Text
.pos_at_start_of_screen_line(State
, State
.cursor1
),
645 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
649 function Text
.right(State
)
650 Text
.right_without_scroll(State
)
651 if Text
.cursor_out_of_screen(State
) then
652 Text
.snap_cursor_to_bottom_of_screen(State
)
656 function Text
.right_without_scroll(State
)
657 assert(State
.lines
[State
.cursor1
.line
].mode
== 'text', 'line is not text')
658 if State
.cursor1
.pos
<= utf8
.len(State
.lines
[State
.cursor1
.line
].data
) then
659 State
.cursor1
.pos
= State
.cursor1
.pos
+1
661 local new_cursor_line
= State
.cursor1
.line
662 while new_cursor_line
<= #State
.lines
-1 do
663 new_cursor_line
= new_cursor_line
+1
664 if State
.lines
[new_cursor_line
].mode
== 'text' then
665 State
.cursor1
= {line
=new_cursor_line
, pos
=1}
672 -- result: pos, index of screen line
673 function Text
.pos_at_start_of_screen_line(State
, loc1
)
674 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
675 local line_cache
= State
.line_cache
[loc1
.line
]
676 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
677 local spos
= line_cache
.screen_line_starting_pos
[i
]
678 if spos
<= loc1
.pos
then
682 assert(false, ('invalid pos %d'):format(loc1
.pos
))
685 function Text
.pos_at_end_of_screen_line(State
, loc1
)
686 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
687 local line_cache
= State
.line_cache
[loc1
.line
]
688 local most_recent_final_pos
= utf8
.len(State
.lines
[loc1
.line
].data
)+1
689 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
690 local spos
= line_cache
.screen_line_starting_pos
[i
]
691 if spos
<= loc1
.pos
then
692 return most_recent_final_pos
694 most_recent_final_pos
= spos
-1
696 assert(false, ('invalid pos %d'):format(loc1
.pos
))
699 function Text
.cursor_at_final_screen_line(State
)
700 Text
.populate_screen_line_starting_pos(State
, State
.cursor1
.line
)
701 local screen_lines
= State
.line_cache
[State
.cursor1
.line
].screen_line_starting_pos
702 --? print(screen_lines[#screen_lines], State.cursor1.pos)
703 return screen_lines
[#screen_lines
] <= State
.cursor1
.pos
706 function Text
.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State
)
708 while State
.cursor1
.line
<= #State
.lines
do
709 if State
.lines
[State
.cursor1
.line
].mode
== 'text' then
712 --? print('cursor skips', State.cursor1.line)
713 y
= y
+ Drawing_padding_height
+ Drawing
.pixels(State
.lines
[State
.cursor1
.line
].h
, State
.width
)
714 State
.cursor1
.line
= State
.cursor1
.line
+ 1
716 if State
.cursor1
.pos
== nil then
717 State
.cursor1
.pos
= 1
719 -- hack: insert a text line at bottom of file if necessary
720 if State
.cursor1
.line
> #State
.lines
then
721 assert(State
.cursor1
.line
== #State
.lines
+1, 'tried to ensure bottom line of file is text, but failed')
722 table.insert(State
.lines
, {mode
='text', data
=''})
723 table.insert(State
.line_cache
, {})
725 --? print(y, App.screen.height, App.screen.height-State.line_height)
726 if y
> App
.screen
.height
- State
.line_height
then
727 --? print('scroll up')
728 Text
.snap_cursor_to_bottom_of_screen(State
)
732 -- should never modify State.cursor1
733 function Text
.snap_cursor_to_bottom_of_screen(State
)
734 --? print('to2:', State.cursor1.line, State.cursor1.pos)
735 local top2
= Text
.to2(State
, State
.cursor1
)
736 --? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos)
737 -- slide to start of screen line
738 top2
.screen_pos
= 1 -- start of screen line
739 --? print('snap', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
740 --? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
741 local y
= App
.screen
.height
- State
.line_height
742 -- duplicate some logic from love.draw
744 --? print(y, 'top2:', top2.line, top2.screen_line, top2.screen_pos)
745 if top2
.line
== 1 and top2
.screen_line
== 1 then break end
746 if top2
.screen_line
> 1 or State
.lines
[top2
.line
-1].mode
== 'text' then
747 local h
= State
.line_height
748 if y
- h
< State
.top
then
753 assert(top2
.line
> 1, 'tried to snap cursor to buttom of screen but failed')
754 assert(State
.lines
[top2
.line
-1].mode
== 'drawing', "expected a drawing but it's not")
755 -- We currently can't draw partial drawings, so either skip it entirely
757 local h
= Drawing_padding_height
+ Drawing
.pixels(State
.lines
[top2
.line
-1].h
, State
.width
)
758 if y
- h
< State
.top
then
761 --? print('skipping drawing of height', h)
764 top2
= Text
.previous_screen_line(State
, top2
)
766 --? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
767 State
.screen_top1
= Text
.to1(State
, top2
)
768 --? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
769 --? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
770 Text
.redraw_all(State
) -- if we're scrolling, reclaim all fragments to avoid memory leaks
773 function Text
.in_line(State
, line_index
, x
,y
)
774 local line
= State
.lines
[line_index
]
775 local line_cache
= State
.line_cache
[line_index
]
776 if line_cache
.starty
== nil then return false end -- outside current page
777 if y
< line_cache
.starty
then return false end
778 Text
.populate_screen_line_starting_pos(State
, line_index
)
779 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)
782 -- convert mx,my in pixels to schema-1 coordinates
783 function Text
.to_pos_on_line(State
, line_index
, mx
, my
)
784 local line
= State
.lines
[line_index
]
785 local line_cache
= State
.line_cache
[line_index
]
786 assert(my
>= line_cache
.starty
, 'failed to map y pixel to line')
787 -- duplicate some logic from Text.draw
788 local y
= line_cache
.starty
789 local start_screen_line_index
= Text
.screen_line_index(line_cache
.screen_line_starting_pos
, line_cache
.startpos
)
790 for screen_line_index
= start_screen_line_index
,#line_cache
.screen_line_starting_pos
do
791 local screen_line_starting_pos
= line_cache
.screen_line_starting_pos
[screen_line_index
]
792 local screen_line_starting_byte_offset
= Text
.offset(line
.data
, screen_line_starting_pos
)
793 --? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
794 local nexty
= y
+ State
.line_height
796 -- On all wrapped screen lines but the final one, clicks past end of
797 -- line position cursor on final character of screen line.
798 -- (The final screen line positions past end of screen line as always.)
799 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
800 --? print('past end of non-final line; return')
801 return line_cache
.screen_line_starting_pos
[screen_line_index
+1]-1
803 local s
= string.sub(line
.data
, screen_line_starting_byte_offset
)
804 --? print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(s, mx, State.left) - 1)
805 return screen_line_starting_pos
+ Text
.nearest_cursor_pos(s
, mx
, State
.left
) - 1
809 assert(false, 'failed to map y pixel to line')
812 function Text
.screen_line_width(State
, line_index
, i
)
813 local line
= State
.lines
[line_index
]
814 local line_cache
= State
.line_cache
[line_index
]
815 local start_pos
= line_cache
.screen_line_starting_pos
[i
]
816 local start_offset
= Text
.offset(line
.data
, start_pos
)
818 if i
< #line_cache
.screen_line_starting_pos
then
819 local past_end_pos
= line_cache
.screen_line_starting_pos
[i
+1]
820 local past_end_offset
= Text
.offset(line
.data
, past_end_pos
)
821 screen_line
= string.sub(line
.data
, start_offset
, past_end_offset
-1)
823 screen_line
= string.sub(line
.data
, start_pos
)
825 return App
.width(screen_line
)
828 function Text
.screen_line_index(screen_line_starting_pos
, pos
)
829 for i
= #screen_line_starting_pos
,1,-1 do
830 if screen_line_starting_pos
[i
] <= pos
then
836 -- convert x pixel coordinate to pos
837 -- oblivious to wrapping
838 -- result: 1 to len+1
839 function Text
.nearest_cursor_pos(line
, x
, left
)
843 local len
= utf8
.len(line
)
844 local max_x
= left
+Text
.x(line
, len
+1)
848 local leftpos
, rightpos
= 1, len
+1
849 --? print('-- nearest', x)
851 --? print('nearest', x, '^'..line..'$', leftpos, rightpos)
852 if leftpos
== rightpos
then
855 local curr
= math
.floor((leftpos
+rightpos
)/2)
856 local currxmin
= left
+Text
.x(line
, curr
)
857 local currxmax
= left
+Text
.x(line
, curr
+1)
858 --? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
859 if currxmin
<= x
and x
< currxmax
then
860 if x
-currxmin
< currxmax
-x
then
866 if leftpos
>= rightpos
-1 then
875 assert(false, 'failed to map x pixel to pos')
878 -- return the nearest index of line (in utf8 code points) which lies entirely
879 -- within x pixels of the left margin
880 -- result: 0 to len+1
881 function Text
.nearest_pos_less_than(line
, x
)
882 --? print('', '-- nearest_pos_less_than', line, x)
883 local len
= utf8
.len(line
)
884 local max_x
= Text
.x_after(line
, len
)
888 local left
, right
= 0, len
+1
890 local curr
= math
.floor((left
+right
)/2)
891 local currxmin
= Text
.x_after(line
, curr
+1)
892 local currxmax
= Text
.x_after(line
, curr
+2)
893 --? print('', x, left, right, curr, currxmin, currxmax)
894 if currxmin
<= x
and x
< currxmax
then
897 if left
>= right
-1 then
906 assert(false, 'failed to map x pixel to pos')
909 function Text
.x_after(s
, pos
)
910 local offset
= Text
.offset(s
, math
.min(pos
+1, #s
+1))
911 local s_before
= s
:sub(1, offset
-1)
912 --? print('^'..s_before..'$')
913 return App
.width(s_before
)
916 function Text
.x(s
, pos
)
917 local offset
= Text
.offset(s
, pos
)
918 local s_before
= s
:sub(1, offset
-1)
919 return App
.width(s_before
)
922 function Text
.to2(State
, loc1
)
923 if State
.lines
[loc1
.line
].mode
== 'drawing' then
924 return {line
=loc1
.line
, screen_line
=1, screen_pos
=1}
926 local result
= {line
=loc1
.line
}
927 local line_cache
= State
.line_cache
[loc1
.line
]
928 Text
.populate_screen_line_starting_pos(State
, loc1
.line
)
929 for i
=#line_cache
.screen_line_starting_pos
,1,-1 do
930 local spos
= line_cache
.screen_line_starting_pos
[i
]
931 if spos
<= loc1
.pos
then
932 result
.screen_line
= i
933 result
.screen_pos
= loc1
.pos
- spos
+ 1
937 assert(result
.screen_pos
, 'failed to convert schema-1 coordinate to schema-2')
941 function Text
.to1(State
, loc2
)
942 local result
= {line
=loc2
.line
, pos
=loc2
.screen_pos
}
943 if loc2
.screen_line
> 1 then
944 result
.pos
= State
.line_cache
[loc2
.line
].screen_line_starting_pos
[loc2
.screen_line
] + loc2
.screen_pos
- 1
949 function Text
.eq1(a
, b
)
950 return a
.line
== b
.line
and a
.pos
== b
.pos
953 function Text
.lt1(a
, b
)
954 if a
.line
< b
.line
then
957 if a
.line
> b
.line
then
963 function Text
.le1(a
, b
)
964 if a
.line
< b
.line
then
967 if a
.line
> b
.line
then
970 return a
.pos
<= b
.pos
973 function Text
.offset(s
, pos1
)
974 if pos1
== 1 then return 1 end
975 local result
= utf8
.offset(s
, pos1
)
976 if result
== nil then
977 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
))
982 function Text
.previous_screen_line(State
, loc2
)
983 if loc2
.screen_line
> 1 then
984 return {line
=loc2
.line
, screen_line
=loc2
.screen_line
-1, screen_pos
=1}
985 elseif loc2
.line
== 1 then
987 elseif State
.lines
[loc2
.line
-1].mode
== 'drawing' then
988 return {line
=loc2
.line
-1, screen_line
=1, screen_pos
=1}
990 local l
= State
.lines
[loc2
.line
-1]
991 Text
.populate_screen_line_starting_pos(State
, loc2
.line
-1)
992 return {line
=loc2
.line
-1, screen_line
=#State
.line_cache
[loc2
.line
-1].screen_line_starting_pos
, screen_pos
=1}
997 function Text
.tweak_screen_top_and_cursor(State
)
998 if State
.screen_top1
.pos
== 1 then return end
999 Text
.populate_screen_line_starting_pos(State
, State
.screen_top1
.line
)
1000 local line
= State
.lines
[State
.screen_top1
.line
]
1001 local line_cache
= State
.line_cache
[State
.screen_top1
.line
]
1002 for i
=2,#line_cache
.screen_line_starting_pos
do
1003 local pos
= line_cache
.screen_line_starting_pos
[i
]
1004 if pos
== State
.screen_top1
.pos
then
1007 if pos
> State
.screen_top1
.pos
then
1008 -- make sure screen top is at start of a screen line
1009 local prev
= line_cache
.screen_line_starting_pos
[i
-1]
1010 if State
.screen_top1
.pos
- prev
< pos
- State
.screen_top1
.pos
then
1011 State
.screen_top1
.pos
= prev
1013 State
.screen_top1
.pos
= pos
1018 -- make sure cursor is on screen
1019 if Text
.lt1(State
.cursor1
, State
.screen_top1
) then
1020 State
.cursor1
= {line
=State
.screen_top1
.line
, pos
=State
.screen_top1
.pos
}
1021 elseif State
.cursor1
.line
>= State
.screen_bottom1
.line
then
1022 --? print('too low')
1023 if Text
.cursor_out_of_screen(State
) then
1026 line
=State
.screen_bottom1
.line
,
1027 pos
=Text
.to_pos_on_line(State
, State
.screen_bottom1
.line
, State
.right
-5, App
.screen
.height
-5),
1033 -- slightly expensive since it redraws the screen
1034 function Text
.cursor_out_of_screen(State
)
1036 return State
.cursor_y
== nil
1037 -- this approach is cheaper and almost works, except on the final screen
1038 -- where file ends above bottom of screen
1039 --? local botpos = Text.pos_at_start_of_screen_line(State, State.cursor1)
1040 --? local botline1 = {line=State.cursor1.line, pos=botpos}
1041 --? return Text.lt1(State.screen_bottom1, botline1)
1044 function Text
.redraw_all(State
)
1045 --? print('clearing fragments')
1046 -- Perform some early sanity checking here, in hopes that we correctly call
1047 -- this whenever we change editor state.
1048 if State
.right
<= State
.left
then
1049 assert(false, ('Right margin %d must be to the right of the left margin %d'):format(State
.right
, State
.left
))
1052 State
.line_cache
= {}
1053 for i
=1,#State
.lines
do
1054 State
.line_cache
[i
] = {}
1058 function Text
.clear_screen_line_cache(State
, line_index
)
1059 State
.line_cache
[line_index
].screen_line_starting_pos
= nil
1060 State
.line_cache
[line_index
].link_offsets
= nil
1064 return s
:gsub('^%s+', ''):gsub('%s+$', '')
1068 return s
:gsub('^%s+', '')
1072 return s
:gsub('%s+$', '')
1075 function starts_with(s
, prefix
)
1076 if #s
< #prefix
then
1080 if s
:sub(i
,i
) ~= prefix
:sub(i
,i
) then
1087 function ends_with(s
, suffix
)
1088 if #s
< #suffix
then
1091 for i
=0,#suffix
-1 do
1092 if s
:sub(#s
-i
,#s
-i
) ~= suffix
:sub(#suffix
-i
,#suffix
-i
) then