bugfix in changing shape mid-stroke
[lines.love.git] / source_text.lua
blob3e70343aa507f5b830bcc928458109f5ed101221
1 -- text editor, particularly text drawing, horizontal wrap, vertical scrolling
2 Text = {}
3 AB_padding = 20 -- space in pixels between A side and B side
5 -- draw a line starting from startpos to screen at y between State.left and State.right
6 -- return the final y, and pos,posB of start of final screen line drawn
7 function Text.draw(State, line_index, y, startpos, startposB, hide_cursor)
8 local line = State.lines[line_index]
9 local line_cache = State.line_cache[line_index]
10 line_cache.starty = y
11 line_cache.startpos = startpos
12 line_cache.startposB = startposB
13 -- draw A side
14 local overflows_screen, x, pos, screen_line_starting_pos
15 if startpos then
16 overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_line(State, line_index, State.left, y, startpos)
17 if overflows_screen then
18 return y, screen_line_starting_pos
19 end
20 if Focus == 'edit' and State.cursor1.pos then
21 if not hide_cursor and not State.search_term then
22 if line_index == State.cursor1.line and State.cursor1.pos == pos then
23 Text.draw_cursor(State, x, y)
24 end
25 end
26 end
27 else
28 x = State.left
29 end
30 -- check for B side
31 --? if line_index == 8 then print('checking for B side') end
32 if line.dataB == nil then
33 assert(y)
34 assert(screen_line_starting_pos)
35 --? if line_index == 8 then print('return 1') end
36 return y, screen_line_starting_pos
37 end
38 if not State.expanded and not line.expanded then
39 assert(y)
40 assert(screen_line_starting_pos)
41 --? if line_index == 8 then print('return 2') end
42 button(State, 'expand', {x=x+AB_padding, y=y+2, w=App.width(State.em), h=State.line_height-4, color={1,1,1},
43 icon = function(button_params)
44 App.color(Fold_background_color)
45 love.graphics.rectangle('fill', button_params.x, button_params.y, App.width(State.em), State.line_height-4, 2,2)
46 end,
47 onpress1 = function()
48 line.expanded = true
49 end,
51 return y, screen_line_starting_pos
52 end
53 -- draw B side
54 --? if line_index == 8 then print('drawing B side') end
55 App.color(Fold_color)
56 if startposB then
57 overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x,y, startposB)
58 else
59 overflows_screen, x, y, pos, screen_line_starting_pos = Text.draw_wrapping_lineB(State, line_index, x+AB_padding,y, 1)
60 end
61 if overflows_screen then
62 return y, nil, screen_line_starting_pos
63 end
64 --? if line_index == 8 then print('a') end
65 if Focus == 'edit' and State.cursor1.posB then
66 --? if line_index == 8 then print('b') end
67 if not hide_cursor and not State.search_term then
68 --? if line_index == 8 then print('c', State.cursor1.line, State.cursor1.posB, line_index, pos) end
69 if line_index == State.cursor1.line and State.cursor1.posB == pos then
70 Text.draw_cursor(State, x, y)
71 end
72 end
73 end
74 return y, nil, screen_line_starting_pos
75 end
77 -- Given an array of fragments, draw the subset starting from pos to screen
78 -- starting from (x,y).
79 -- Return:
80 -- - whether we got to bottom of screen before end of line
81 -- - the final (x,y)
82 -- - the final pos
83 -- - starting pos of the final screen line drawn
84 function Text.draw_wrapping_line(State, line_index, x,y, startpos)
85 local line = State.lines[line_index]
86 local line_cache = State.line_cache[line_index]
87 --? print('== line', line_index, '^'..line.data..'$')
88 local screen_line_starting_pos = startpos
89 Text.compute_fragments(State, line_index)
90 local pos = 1
91 initialize_color()
92 for _, f in ipairs(line_cache.fragments) do
93 App.color(Text_color)
94 local frag, frag_text = f.data, f.text
95 select_color(frag)
96 local frag_len = utf8.len(frag)
97 --? print('text.draw:', frag, 'at', line_index,pos, 'after', x,y)
98 if pos < startpos then
99 -- render nothing
100 --? print('skipping', frag)
101 else
102 -- render fragment
103 local frag_width = App.width(frag_text)
104 if x + frag_width > State.right then
105 assert(x > State.left) -- no overfull lines
106 y = y + State.line_height
107 if y + State.line_height > App.screen.height then
108 return --[[screen filled]] true, x,y, pos, screen_line_starting_pos
110 screen_line_starting_pos = pos
111 x = State.left
113 if State.selection1.line then
114 local lo, hi = Text.clip_selection(State, line_index, pos, pos+frag_len)
115 Text.draw_highlight(State, line, x,y, pos, lo,hi)
117 -- Make [[WikiWords]] (single word, all in one screen line) clickable.
118 local trimmed_word = rtrim(frag) -- compute_fragments puts whitespace at the end
119 if starts_with(trimmed_word, '[[') and ends_with(trimmed_word, ']]') then
120 local filename = trimmed_word:gsub('^..(.*)..$', '%1')
121 if source.link_exists(State, filename) then
122 local filename_text = App.newText(love.graphics.getFont(), filename)
123 button(State, 'link', {x=x+App.width(to_text('[[')), y=y, w=App.width(filename_text), h=State.line_height, color={1,1,1},
124 icon = icon.hyperlink_decoration,
125 onpress1 = function()
126 source.switch_to_file(filename)
127 end,
131 App.screen.draw(frag_text, x,y)
132 -- render cursor if necessary
133 if State.cursor1.pos and line_index == State.cursor1.line then
134 if pos <= State.cursor1.pos and pos + frag_len > State.cursor1.pos then
135 if State.search_term then
136 if State.lines[State.cursor1.line].data:sub(State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term)-1) == State.search_term then
137 local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.pos, State.cursor1.pos+utf8.len(State.search_term))
138 App.color(Text_color)
139 love.graphics.print(State.search_term, x+lo_px,y)
141 elseif Focus == 'edit' then
142 Text.draw_cursor(State, x+Text.x(frag, State.cursor1.pos-pos+1), y)
143 App.color(Text_color)
147 x = x + frag_width
149 pos = pos + frag_len
151 return false, x,y, pos, screen_line_starting_pos
154 function Text.draw_wrapping_lineB(State, line_index, x,y, startpos)
155 local line = State.lines[line_index]
156 local line_cache = State.line_cache[line_index]
157 local screen_line_starting_pos = startpos
158 Text.compute_fragmentsB(State, line_index, x)
159 local pos = 1
160 for _, f in ipairs(line_cache.fragmentsB) do
161 local frag, frag_text = f.data, f.text
162 local frag_len = utf8.len(frag)
163 --? print('text.draw:', frag, 'at', line_index,pos, 'after', x,y)
164 if pos < startpos then
165 -- render nothing
166 --? print('skipping', frag)
167 else
168 -- render fragment
169 local frag_width = App.width(frag_text)
170 if x + frag_width > State.right then
171 assert(x > State.left) -- no overfull lines
172 y = y + State.line_height
173 if y + State.line_height > App.screen.height then
174 return --[[screen filled]] true, x,y, pos, screen_line_starting_pos
176 screen_line_starting_pos = pos
177 x = State.left
179 if State.selection1.line then
180 local lo, hi = Text.clip_selection(State, line_index, pos, pos+frag_len)
181 Text.draw_highlight(State, line, x,y, pos, lo,hi)
183 App.screen.draw(frag_text, x,y)
184 -- render cursor if necessary
185 if State.cursor1.posB and line_index == State.cursor1.line then
186 if pos <= State.cursor1.posB and pos + frag_len > State.cursor1.posB then
187 if State.search_term then
188 if State.lines[State.cursor1.line].dataB:sub(State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term)-1) == State.search_term then
189 local lo_px = Text.draw_highlight(State, line, x,y, pos, State.cursor1.posB, State.cursor1.posB+utf8.len(State.search_term))
190 App.color(Fold_color)
191 love.graphics.print(State.search_term, x+lo_px,y)
193 elseif Focus == 'edit' then
194 Text.draw_cursor(State, x+Text.x(frag, State.cursor1.posB-pos+1), y)
195 App.color(Fold_color)
199 x = x + frag_width
201 pos = pos + frag_len
203 return false, x,y, pos, screen_line_starting_pos
206 function Text.draw_cursor(State, x, y)
207 -- blink every 0.5s
208 if math.floor(Cursor_time*2)%2 == 0 then
209 App.color(Cursor_color)
210 love.graphics.rectangle('fill', x,y, 3,State.line_height)
212 State.cursor_x = x
213 State.cursor_y = y+State.line_height
216 function Text.populate_screen_line_starting_pos(State, line_index)
217 local line = State.lines[line_index]
218 if line.mode ~= 'text' then return end
219 local line_cache = State.line_cache[line_index]
220 if line_cache.screen_line_starting_pos then
221 return
223 -- duplicate some logic from Text.draw
224 Text.compute_fragments(State, line_index)
225 line_cache.screen_line_starting_pos = {1}
226 local x = State.left
227 local pos = 1
228 for _, f in ipairs(line_cache.fragments) do
229 local frag, frag_text = f.data, f.text
230 -- render fragment
231 local frag_width = App.width(frag_text)
232 if x + frag_width > State.right then
233 x = State.left
234 table.insert(line_cache.screen_line_starting_pos, pos)
236 x = x + frag_width
237 local frag_len = utf8.len(frag)
238 pos = pos + frag_len
242 function Text.compute_fragments(State, line_index)
243 --? print('compute_fragments', line_index, 'between', State.left, State.right)
244 local line = State.lines[line_index]
245 if line.mode ~= 'text' then return end
246 local line_cache = State.line_cache[line_index]
247 if line_cache.fragments then
248 return
250 line_cache.fragments = {}
251 local x = State.left
252 -- try to wrap at word boundaries
253 for frag in line.data:gmatch('%S*%s*') do
254 local frag_text = App.newText(love.graphics.getFont(), frag)
255 local frag_width = App.width(frag_text)
256 --? print('x: '..tostring(x)..'; frag_width: '..tostring(frag_width)..'; '..tostring(State.right-x)..'px to go')
257 while x + frag_width > State.right do
258 --? print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x))
259 if (x-State.left) < 0.8 * (State.right-State.left) then
260 --? print('splitting')
261 -- long word; chop it at some letter
262 -- We're not going to reimplement TeX here.
263 local bpos = Text.nearest_pos_less_than(frag, State.right - x)
264 --? print('bpos', bpos)
265 if bpos == 0 then break end -- avoid infinite loop when window is too narrow
266 local boffset = Text.offset(frag, bpos+1) -- byte _after_ bpos
267 --? print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes')
268 local frag1 = string.sub(frag, 1, boffset-1)
269 local frag1_text = App.newText(love.graphics.getFont(), frag1)
270 local frag1_width = App.width(frag1_text)
271 --? print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px')
272 assert(x + frag1_width <= State.right)
273 table.insert(line_cache.fragments, {data=frag1, text=frag1_text})
274 frag = string.sub(frag, boffset)
275 frag_text = App.newText(love.graphics.getFont(), frag)
276 frag_width = App.width(frag_text)
278 x = State.left -- new line
280 if #frag > 0 then
281 --? print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px')
282 table.insert(line_cache.fragments, {data=frag, text=frag_text})
284 x = x + frag_width
288 function Text.populate_screen_line_starting_posB(State, line_index, x)
289 local line = State.lines[line_index]
290 local line_cache = State.line_cache[line_index]
291 if line_cache.screen_line_starting_posB then
292 return
294 -- duplicate some logic from Text.draw
295 Text.compute_fragmentsB(State, line_index, x)
296 line_cache.screen_line_starting_posB = {1}
297 local pos = 1
298 for _, f in ipairs(line_cache.fragmentsB) do
299 local frag, frag_text = f.data, f.text
300 -- render fragment
301 local frag_width = App.width(frag_text)
302 if x + frag_width > State.right then
303 x = State.left
304 table.insert(line_cache.screen_line_starting_posB, pos)
306 x = x + frag_width
307 local frag_len = utf8.len(frag)
308 pos = pos + frag_len
312 function Text.compute_fragmentsB(State, line_index, x)
313 --? print('compute_fragmentsB', line_index, 'between', x, State.right)
314 local line = State.lines[line_index]
315 local line_cache = State.line_cache[line_index]
316 if line_cache.fragmentsB then
317 return
319 line_cache.fragmentsB = {}
320 -- try to wrap at word boundaries
321 for frag in line.dataB:gmatch('%S*%s*') do
322 local frag_text = App.newText(love.graphics.getFont(), frag)
323 local frag_width = App.width(frag_text)
324 --? print('x: '..tostring(x)..'; '..tostring(State.right-x)..'px to go')
325 while x + frag_width > State.right do
326 --? print(('checking whether to split fragment ^%s$ of width %d when rendering from %d'):format(frag, frag_width, x))
327 if (x-State.left) < 0.8 * (State.right-State.left) then
328 --? print('splitting')
329 -- long word; chop it at some letter
330 -- We're not going to reimplement TeX here.
331 local bpos = Text.nearest_pos_less_than(frag, State.right - x)
332 --? print('bpos', bpos)
333 if bpos == 0 then break end -- avoid infinite loop when window is too narrow
334 local boffset = Text.offset(frag, bpos+1) -- byte _after_ bpos
335 --? print('space for '..tostring(bpos)..' graphemes, '..tostring(boffset-1)..' bytes')
336 local frag1 = string.sub(frag, 1, boffset-1)
337 local frag1_text = App.newText(love.graphics.getFont(), frag1)
338 local frag1_width = App.width(frag1_text)
339 --? print('extracting ^'..frag1..'$ of width '..tostring(frag1_width)..'px')
340 assert(x + frag1_width <= State.right)
341 table.insert(line_cache.fragmentsB, {data=frag1, text=frag1_text})
342 frag = string.sub(frag, boffset)
343 frag_text = App.newText(love.graphics.getFont(), frag)
344 frag_width = App.width(frag_text)
346 x = State.left -- new line
348 if #frag > 0 then
349 --? print('inserting ^'..frag..'$ of width '..tostring(frag_width)..'px')
350 table.insert(line_cache.fragmentsB, {data=frag, text=frag_text})
352 x = x + frag_width
356 function Text.textinput(State, t)
357 if App.mouse_down(1) then return end
358 if App.ctrl_down() or App.alt_down() or App.cmd_down() then return end
359 local before = snapshot(State, State.cursor1.line)
360 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
361 Text.insert_at_cursor(State, t)
362 if State.cursor_y > App.screen.height - State.line_height then
363 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
364 Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
366 record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
369 function Text.insert_at_cursor(State, t)
370 if State.cursor1.pos then
371 local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
372 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)
373 Text.clear_screen_line_cache(State, State.cursor1.line)
374 State.cursor1.pos = State.cursor1.pos+1
375 else
376 assert(State.cursor1.posB)
377 local byte_offset = Text.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
378 State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_offset-1)..t..string.sub(State.lines[State.cursor1.line].dataB, byte_offset)
379 Text.clear_screen_line_cache(State, State.cursor1.line)
380 State.cursor1.posB = State.cursor1.posB+1
384 -- Don't handle any keys here that would trigger love.textinput above.
385 function Text.keychord_pressed(State, chord)
386 --? print('chord', chord, State.selection1.line, State.selection1.pos)
387 --== shortcuts that mutate text
388 if chord == 'return' then
389 local before_line = State.cursor1.line
390 local before = snapshot(State, before_line)
391 Text.insert_return(State)
392 State.selection1 = {}
393 if State.cursor_y > App.screen.height - State.line_height then
394 Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
396 schedule_save(State)
397 record_undo_event(State, {before=before, after=snapshot(State, before_line, State.cursor1.line)})
398 elseif chord == 'tab' then
399 local before = snapshot(State, State.cursor1.line)
400 --? print(State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
401 Text.insert_at_cursor(State, '\t')
402 if State.cursor_y > App.screen.height - State.line_height then
403 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
404 Text.snap_cursor_to_bottom_of_screen(State, State.left, State.right)
405 --? print('=>', State.screen_top1.line, State.screen_top1.pos, State.cursor1.line, State.cursor1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
407 schedule_save(State)
408 record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
409 elseif chord == 'backspace' then
410 if State.selection1.line then
411 Text.delete_selection(State, State.left, State.right)
412 schedule_save(State)
413 return
415 local before
416 if State.cursor1.pos and State.cursor1.pos > 1 then
417 before = snapshot(State, State.cursor1.line)
418 local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos-1)
419 local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
420 if byte_start then
421 if byte_end then
422 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)
423 else
424 State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
426 State.cursor1.pos = State.cursor1.pos-1
428 elseif State.cursor1.posB then
429 if State.cursor1.posB > 1 then
430 before = snapshot(State, State.cursor1.line)
431 local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1)
432 local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
433 if byte_start then
434 if byte_end then
435 State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end)
436 else
437 State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)
439 State.cursor1.posB = State.cursor1.posB-1
441 else
442 -- refuse to delete past beginning of side B
444 elseif State.cursor1.line > 1 then
445 before = snapshot(State, State.cursor1.line-1, State.cursor1.line)
446 if State.lines[State.cursor1.line-1].mode == 'drawing' then
447 table.remove(State.lines, State.cursor1.line-1)
448 table.remove(State.line_cache, State.cursor1.line-1)
449 else
450 -- join lines
451 State.cursor1.pos = utf8.len(State.lines[State.cursor1.line-1].data)+1
452 State.lines[State.cursor1.line-1].data = State.lines[State.cursor1.line-1].data..State.lines[State.cursor1.line].data
453 table.remove(State.lines, State.cursor1.line)
454 table.remove(State.line_cache, State.cursor1.line)
456 State.cursor1.line = State.cursor1.line-1
458 if State.screen_top1.line > #State.lines then
459 Text.populate_screen_line_starting_pos(State, #State.lines)
460 local line_cache = State.line_cache[#State.line_cache]
461 State.screen_top1 = {line=#State.lines, pos=line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]}
462 elseif Text.lt1(State.cursor1, State.screen_top1) then
463 local top2 = Text.to2(State, State.screen_top1)
464 top2 = Text.previous_screen_line(State, top2, State.left, State.right)
465 State.screen_top1 = Text.to1(State, top2)
466 Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks
468 Text.clear_screen_line_cache(State, State.cursor1.line)
469 assert(Text.le1(State.screen_top1, State.cursor1))
470 schedule_save(State)
471 record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
472 elseif chord == 'delete' then
473 if State.selection1.line then
474 Text.delete_selection(State, State.left, State.right)
475 schedule_save(State)
476 return
478 local before
479 if State.cursor1.posB or State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
480 before = snapshot(State, State.cursor1.line)
481 else
482 before = snapshot(State, State.cursor1.line, State.cursor1.line+1)
484 if State.cursor1.pos and State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
485 local byte_start = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
486 local byte_end = utf8.offset(State.lines[State.cursor1.line].data, State.cursor1.pos+1)
487 if byte_start then
488 if byte_end then
489 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)
490 else
491 State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_start-1)
493 -- no change to State.cursor1.pos
495 elseif State.cursor1.posB then
496 if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then
497 local byte_start = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB)
498 local byte_end = utf8.offset(State.lines[State.cursor1.line].dataB, State.cursor1.posB+1)
499 if byte_start then
500 if byte_end then
501 State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)..string.sub(State.lines[State.cursor1.line].dataB, byte_end)
502 else
503 State.lines[State.cursor1.line].dataB = string.sub(State.lines[State.cursor1.line].dataB, 1, byte_start-1)
505 -- no change to State.cursor1.pos
507 else
508 -- refuse to delete past end of side B
510 elseif State.cursor1.line < #State.lines then
511 if State.lines[State.cursor1.line+1].mode == 'text' then
512 -- join lines
513 State.lines[State.cursor1.line].data = State.lines[State.cursor1.line].data..State.lines[State.cursor1.line+1].data
514 -- delete side B on first line
515 State.lines[State.cursor1.line].dataB = State.lines[State.cursor1.line+1].dataB
517 table.remove(State.lines, State.cursor1.line+1)
518 table.remove(State.line_cache, State.cursor1.line+1)
520 Text.clear_screen_line_cache(State, State.cursor1.line)
521 schedule_save(State)
522 record_undo_event(State, {before=before, after=snapshot(State, State.cursor1.line)})
523 --== shortcuts that move the cursor
524 elseif chord == 'left' then
525 Text.left(State)
526 State.selection1 = {}
527 elseif chord == 'right' then
528 Text.right(State)
529 State.selection1 = {}
530 elseif chord == 'S-left' then
531 if State.selection1.line == nil then
532 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
534 Text.left(State)
535 elseif chord == 'S-right' then
536 if State.selection1.line == nil then
537 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
539 Text.right(State)
540 -- C- hotkeys reserved for drawings, so we'll use M-
541 elseif chord == 'M-left' then
542 Text.word_left(State)
543 State.selection1 = {}
544 elseif chord == 'M-right' then
545 Text.word_right(State)
546 State.selection1 = {}
547 elseif chord == 'M-S-left' then
548 if State.selection1.line == nil then
549 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
551 Text.word_left(State)
552 elseif chord == 'M-S-right' then
553 if State.selection1.line == nil then
554 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
556 Text.word_right(State)
557 elseif chord == 'home' then
558 Text.start_of_line(State)
559 State.selection1 = {}
560 elseif chord == 'end' then
561 Text.end_of_line(State)
562 State.selection1 = {}
563 elseif chord == 'S-home' then
564 if State.selection1.line == nil then
565 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
567 Text.start_of_line(State)
568 elseif chord == 'S-end' then
569 if State.selection1.line == nil then
570 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
572 Text.end_of_line(State)
573 elseif chord == 'up' then
574 Text.up(State)
575 State.selection1 = {}
576 elseif chord == 'down' then
577 Text.down(State)
578 State.selection1 = {}
579 elseif chord == 'S-up' then
580 if State.selection1.line == nil then
581 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
583 Text.up(State)
584 elseif chord == 'S-down' then
585 if State.selection1.line == nil then
586 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
588 Text.down(State)
589 elseif chord == 'pageup' then
590 Text.pageup(State)
591 State.selection1 = {}
592 elseif chord == 'pagedown' then
593 Text.pagedown(State)
594 State.selection1 = {}
595 elseif chord == 'S-pageup' then
596 if State.selection1.line == nil then
597 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
599 Text.pageup(State)
600 elseif chord == 'S-pagedown' then
601 if State.selection1.line == nil then
602 State.selection1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB}
604 Text.pagedown(State)
608 function Text.insert_return(State)
609 if State.cursor1.pos then
610 -- when inserting a newline, move any B side to the new line
611 local byte_offset = Text.offset(State.lines[State.cursor1.line].data, State.cursor1.pos)
612 table.insert(State.lines, State.cursor1.line+1, {mode='text', data=string.sub(State.lines[State.cursor1.line].data, byte_offset), dataB=State.lines[State.cursor1.line].dataB})
613 table.insert(State.line_cache, State.cursor1.line+1, {})
614 State.lines[State.cursor1.line].data = string.sub(State.lines[State.cursor1.line].data, 1, byte_offset-1)
615 State.lines[State.cursor1.line].dataB = nil
616 Text.clear_screen_line_cache(State, State.cursor1.line)
617 State.cursor1 = {line=State.cursor1.line+1, pos=1}
618 else
619 -- disable enter when cursor is on the B side
623 function Text.pageup(State)
624 --? print('pageup')
625 -- duplicate some logic from love.draw
626 local top2 = Text.to2(State, State.screen_top1)
627 --? print(App.screen.height)
628 local y = App.screen.height - State.line_height
629 while y >= State.top do
630 --? print(y, top2.line, top2.screen_line, top2.screen_pos)
631 if State.screen_top1.line == 1 and State.screen_top1.pos and State.screen_top1.pos == 1 then break end
632 if State.lines[State.screen_top1.line].mode == 'text' then
633 y = y - State.line_height
634 elseif State.lines[State.screen_top1.line].mode == 'drawing' then
635 y = y - Drawing_padding_height - Drawing.pixels(State.lines[State.screen_top1.line].h, State.width)
637 top2 = Text.previous_screen_line(State, top2)
639 State.screen_top1 = Text.to1(State, top2)
640 State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB}
641 Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
642 --? print(State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
643 --? print('pageup end')
646 function Text.pagedown(State)
647 --? print('pagedown')
648 local bot2 = Text.to2(State, State.screen_bottom1)
649 local new_top1 = Text.to1(State, bot2)
650 if Text.lt1(State.screen_top1, new_top1) then
651 State.screen_top1 = new_top1
652 else
653 State.screen_top1 = {line=State.screen_bottom1.line, pos=State.screen_bottom1.pos, posB=State.screen_bottom1.posB}
655 --? print('setting top to', State.screen_top1.line, State.screen_top1.pos)
656 State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos, posB=State.screen_top1.posB}
657 Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
658 --? print('top now', State.screen_top1.line)
659 Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks
660 --? print('pagedown end')
663 function Text.up(State)
664 assert(State.lines[State.cursor1.line].mode == 'text')
665 if State.cursor1.pos then
666 Text.upA(State)
667 else
668 Text.upB(State)
672 function Text.upA(State)
673 --? print('up', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos)
674 local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
675 if screen_line_starting_pos == 1 then
676 --? print('cursor is at first screen line of its line')
677 -- line is done; skip to previous text line
678 local new_cursor_line = State.cursor1.line
679 while new_cursor_line > 1 do
680 new_cursor_line = new_cursor_line-1
681 if State.lines[new_cursor_line].mode == 'text' then
682 --? print('found previous text line')
683 State.cursor1 = {line=State.cursor1.line-1, pos=nil}
684 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
685 -- previous text line found, pick its final screen line
686 --? print('has multiple screen lines')
687 local screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos
688 --? print(#screen_line_starting_pos)
689 screen_line_starting_pos = screen_line_starting_pos[#screen_line_starting_pos]
690 local screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, screen_line_starting_pos)
691 local s = string.sub(State.lines[State.cursor1.line].data, screen_line_starting_byte_offset)
692 State.cursor1.pos = screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
693 break
696 else
697 -- move up one screen line in current line
698 assert(screen_line_index > 1)
699 local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index-1]
700 local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
701 local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
702 State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
703 --? print('cursor pos is now '..tostring(State.cursor1.pos))
705 if Text.lt1(State.cursor1, State.screen_top1) then
706 local top2 = Text.to2(State, State.screen_top1)
707 top2 = Text.previous_screen_line(State, top2)
708 State.screen_top1 = Text.to1(State, top2)
712 function Text.upB(State)
713 local line_cache = State.line_cache[State.cursor1.line]
714 local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1)
715 assert(screen_line_indexB >= 1)
716 if screen_line_indexB == 1 then
717 -- move to A side of previous line
718 local new_cursor_line = State.cursor1.line
719 while new_cursor_line > 1 do
720 new_cursor_line = new_cursor_line-1
721 if State.lines[new_cursor_line].mode == 'text' then
722 State.cursor1 = {line=State.cursor1.line-1, posB=nil}
723 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
724 local prev_line_cache = State.line_cache[State.cursor1.line]
725 local prev_screen_line_starting_pos = prev_line_cache.screen_line_starting_pos[#prev_line_cache.screen_line_starting_pos]
726 local prev_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, prev_screen_line_starting_pos)
727 local s = string.sub(State.lines[State.cursor1.line].data, prev_screen_line_starting_byte_offset)
728 State.cursor1.pos = prev_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
729 break
732 elseif screen_line_indexB == 2 then
733 -- all-B screen-line to potentially A+B screen-line
734 local xA = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding
735 if State.cursor_x < xA then
736 State.cursor1.posB = nil
737 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
738 local new_screen_line_starting_pos = line_cache.screen_line_starting_pos[#line_cache.screen_line_starting_pos]
739 local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
740 local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
741 State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
742 else
743 Text.populate_screen_line_starting_posB(State, State.cursor1.line)
744 local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1]
745 local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB)
746 local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB)
747 State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x-xA, State.left) - 1
749 else
750 assert(screen_line_indexB > 2)
751 -- all-B screen-line to all-B screen-line
752 Text.populate_screen_line_starting_posB(State, State.cursor1.line)
753 local new_screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB-1]
754 local new_screen_line_starting_byte_offsetB = Text.offset(State.lines[State.cursor1.line].dataB, new_screen_line_starting_posB)
755 local s = string.sub(State.lines[State.cursor1.line].dataB, new_screen_line_starting_byte_offsetB)
756 State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
758 if Text.lt1(State.cursor1, State.screen_top1) then
759 local top2 = Text.to2(State, State.screen_top1)
760 top2 = Text.previous_screen_line(State, top2)
761 State.screen_top1 = Text.to1(State, top2)
765 -- cursor on final screen line (A or B side) => goes to next screen line on A side
766 -- cursor on A side => move down one screen line (A side) in current line
767 -- cursor on B side => move down one screen line (B side) in current line
768 function Text.down(State)
769 assert(State.lines[State.cursor1.line].mode == 'text')
770 --? print('down', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
771 if Text.cursor_at_final_screen_line(State) then
772 -- line is done, skip to next text line
773 --? print('cursor at final screen line of its line')
774 local new_cursor_line = State.cursor1.line
775 while new_cursor_line < #State.lines do
776 new_cursor_line = new_cursor_line+1
777 if State.lines[new_cursor_line].mode == 'text' then
778 State.cursor1 = {
779 line = new_cursor_line,
780 pos = Text.nearest_cursor_pos(State.lines[new_cursor_line].data, State.cursor_x, State.left),
782 --? print(State.cursor1.pos)
783 break
786 if State.cursor1.line > State.screen_bottom1.line then
787 --? print('screen top before:', State.screen_top1.line, State.screen_top1.pos)
788 --? print('scroll up preserving cursor')
789 Text.snap_cursor_to_bottom_of_screen(State)
790 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
792 elseif State.cursor1.pos then
793 -- move down one screen line (A side) in current line
794 local scroll_down = Text.le1(State.screen_bottom1, State.cursor1)
795 --? print('cursor is NOT at final screen line of its line')
796 local screen_line_starting_pos, screen_line_index = Text.pos_at_start_of_screen_line(State, State.cursor1)
797 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
798 local new_screen_line_starting_pos = State.line_cache[State.cursor1.line].screen_line_starting_pos[screen_line_index+1]
799 --? print('switching pos of screen line at cursor from '..tostring(screen_line_starting_pos)..' to '..tostring(new_screen_line_starting_pos))
800 local new_screen_line_starting_byte_offset = Text.offset(State.lines[State.cursor1.line].data, new_screen_line_starting_pos)
801 local s = string.sub(State.lines[State.cursor1.line].data, new_screen_line_starting_byte_offset)
802 State.cursor1.pos = new_screen_line_starting_pos + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
803 --? print('cursor pos is now', State.cursor1.line, State.cursor1.pos)
804 if scroll_down then
805 --? print('scroll up preserving cursor')
806 Text.snap_cursor_to_bottom_of_screen(State)
807 --? print('screen top after:', State.screen_top1.line, State.screen_top1.pos)
809 else
810 -- move down one screen line (B side) in current line
811 local scroll_down = false
812 if Text.le1(State.screen_bottom1, State.cursor1) then
813 scroll_down = true
815 local cursor_line = State.lines[State.cursor1.line]
816 local cursor_line_cache = State.line_cache[State.cursor1.line]
817 local cursor2 = Text.to2(State, State.cursor1)
818 assert(cursor2.screen_lineB < #cursor_line_cache.screen_line_starting_posB)
819 local screen_line_starting_posB, screen_line_indexB = Text.pos_at_start_of_screen_lineB(State, State.cursor1)
820 Text.populate_screen_line_starting_posB(State, State.cursor1.line)
821 local new_screen_line_starting_posB = cursor_line_cache.screen_line_starting_posB[screen_line_indexB+1]
822 local new_screen_line_starting_byte_offsetB = Text.offset(cursor_line.dataB, new_screen_line_starting_posB)
823 local s = string.sub(cursor_line.dataB, new_screen_line_starting_byte_offsetB)
824 State.cursor1.posB = new_screen_line_starting_posB + Text.nearest_cursor_pos(s, State.cursor_x, State.left) - 1
825 if scroll_down then
826 Text.snap_cursor_to_bottom_of_screen(State)
829 --? print('=>', State.cursor1.line, State.cursor1.pos, State.screen_top1.line, State.screen_top1.pos, State.screen_bottom1.line, State.screen_bottom1.pos)
832 function Text.start_of_line(State)
833 if State.cursor1.pos then
834 State.cursor1.pos = 1
835 else
836 State.cursor1.posB = 1
838 if Text.lt1(State.cursor1, State.screen_top1) then
839 State.screen_top1 = {line=State.cursor1.line, pos=State.cursor1.pos, posB=State.cursor1.posB} -- copy
843 function Text.end_of_line(State)
844 if State.cursor1.pos then
845 State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
846 else
847 State.cursor1.posB = utf8.len(State.lines[State.cursor1.line].dataB) + 1
849 if Text.cursor_out_of_screen(State) then
850 Text.snap_cursor_to_bottom_of_screen(State)
854 function Text.word_left(State)
855 -- we can cross the fold, so check side A/B one level down
856 Text.skip_whitespace_left(State)
857 Text.left(State)
858 Text.skip_non_whitespace_left(State)
861 function Text.word_right(State)
862 -- we can cross the fold, so check side A/B one level down
863 Text.skip_whitespace_right(State)
864 Text.right(State)
865 Text.skip_non_whitespace_right(State)
866 if Text.cursor_out_of_screen(State) then
867 Text.snap_cursor_to_bottom_of_screen(State)
871 function Text.skip_whitespace_left(State)
872 if State.cursor1.pos then
873 Text.skip_whitespace_leftA(State)
874 else
875 Text.skip_whitespace_leftB(State)
879 function Text.skip_non_whitespace_left(State)
880 if State.cursor1.pos then
881 Text.skip_non_whitespace_leftA(State)
882 else
883 Text.skip_non_whitespace_leftB(State)
887 function Text.skip_whitespace_leftA(State)
888 while true do
889 if State.cursor1.pos == 1 then
890 break
892 if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%S') then
893 break
895 Text.left(State)
899 function Text.skip_whitespace_leftB(State)
900 while true do
901 if State.cursor1.posB == 1 then
902 break
904 if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%S') then
905 break
907 Text.left(State)
911 function Text.skip_non_whitespace_leftA(State)
912 while true do
913 if State.cursor1.pos == 1 then
914 break
916 assert(State.cursor1.pos > 1)
917 if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos-1, '%s') then
918 break
920 Text.left(State)
924 function Text.skip_non_whitespace_leftB(State)
925 while true do
926 if State.cursor1.posB == 1 then
927 break
929 assert(State.cursor1.posB > 1)
930 if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB-1, '%s') then
931 break
933 Text.left(State)
937 function Text.skip_whitespace_right(State)
938 if State.cursor1.pos then
939 Text.skip_whitespace_rightA(State)
940 else
941 Text.skip_whitespace_rightB(State)
945 function Text.skip_non_whitespace_right(State)
946 if State.cursor1.pos then
947 Text.skip_non_whitespace_rightA(State)
948 else
949 Text.skip_non_whitespace_rightB(State)
953 function Text.skip_whitespace_rightA(State)
954 while true do
955 if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
956 break
958 if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%S') then
959 break
961 Text.right_without_scroll(State)
965 function Text.skip_whitespace_rightB(State)
966 while true do
967 if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then
968 break
970 if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%S') then
971 break
973 Text.right_without_scroll(State)
977 function Text.skip_non_whitespace_rightA(State)
978 while true do
979 if State.cursor1.pos > utf8.len(State.lines[State.cursor1.line].data) then
980 break
982 if Text.match(State.lines[State.cursor1.line].data, State.cursor1.pos, '%s') then
983 break
985 Text.right_without_scroll(State)
989 function Text.skip_non_whitespace_rightB(State)
990 while true do
991 if State.cursor1.posB > utf8.len(State.lines[State.cursor1.line].dataB) then
992 break
994 if Text.match(State.lines[State.cursor1.line].dataB, State.cursor1.posB, '%s') then
995 break
997 Text.right_without_scroll(State)
1001 function Text.match(s, pos, pat)
1002 local start_offset = Text.offset(s, pos)
1003 assert(start_offset)
1004 local end_offset = Text.offset(s, pos+1)
1005 assert(end_offset > start_offset)
1006 local curr = s:sub(start_offset, end_offset-1)
1007 return curr:match(pat)
1010 function Text.left(State)
1011 if State.cursor1.pos then
1012 Text.leftA(State)
1013 else
1014 Text.leftB(State)
1018 function Text.leftA(State)
1019 if State.cursor1.pos > 1 then
1020 State.cursor1.pos = State.cursor1.pos-1
1021 else
1022 local new_cursor_line = State.cursor1.line
1023 while new_cursor_line > 1 do
1024 new_cursor_line = new_cursor_line-1
1025 if State.lines[new_cursor_line].mode == 'text' then
1026 State.cursor1 = {
1027 line = new_cursor_line,
1028 pos = utf8.len(State.lines[new_cursor_line].data) + 1,
1030 break
1034 if Text.lt1(State.cursor1, State.screen_top1) then
1035 local top2 = Text.to2(State, State.screen_top1)
1036 top2 = Text.previous_screen_line(State, top2)
1037 State.screen_top1 = Text.to1(State, top2)
1041 function Text.leftB(State)
1042 if State.cursor1.posB > 1 then
1043 State.cursor1.posB = State.cursor1.posB-1
1044 else
1045 -- overflow back into A side
1046 State.cursor1.posB = nil
1047 State.cursor1.pos = utf8.len(State.lines[State.cursor1.line].data) + 1
1049 if Text.lt1(State.cursor1, State.screen_top1) then
1050 local top2 = Text.to2(State, State.screen_top1)
1051 top2 = Text.previous_screen_line(State, top2)
1052 State.screen_top1 = Text.to1(State, top2)
1056 function Text.right(State)
1057 Text.right_without_scroll(State)
1058 if Text.cursor_out_of_screen(State) then
1059 Text.snap_cursor_to_bottom_of_screen(State)
1063 function Text.right_without_scroll(State)
1064 assert(State.lines[State.cursor1.line].mode == 'text')
1065 if State.cursor1.pos then
1066 Text.right_without_scrollA(State)
1067 else
1068 Text.right_without_scrollB(State)
1072 function Text.right_without_scrollA(State)
1073 if State.cursor1.pos <= utf8.len(State.lines[State.cursor1.line].data) then
1074 State.cursor1.pos = State.cursor1.pos+1
1075 else
1076 local new_cursor_line = State.cursor1.line
1077 while new_cursor_line <= #State.lines-1 do
1078 new_cursor_line = new_cursor_line+1
1079 if State.lines[new_cursor_line].mode == 'text' then
1080 State.cursor1 = {line=new_cursor_line, pos=1}
1081 break
1087 function Text.right_without_scrollB(State)
1088 if State.cursor1.posB <= utf8.len(State.lines[State.cursor1.line].dataB) then
1089 State.cursor1.posB = State.cursor1.posB+1
1090 else
1091 -- overflow back into A side
1092 local new_cursor_line = State.cursor1.line
1093 while new_cursor_line <= #State.lines-1 do
1094 new_cursor_line = new_cursor_line+1
1095 if State.lines[new_cursor_line].mode == 'text' then
1096 State.cursor1 = {line=new_cursor_line, pos=1}
1097 break
1103 function Text.pos_at_start_of_screen_line(State, loc1)
1104 Text.populate_screen_line_starting_pos(State, loc1.line)
1105 local line_cache = State.line_cache[loc1.line]
1106 for i=#line_cache.screen_line_starting_pos,1,-1 do
1107 local spos = line_cache.screen_line_starting_pos[i]
1108 if spos <= loc1.pos then
1109 return spos,i
1112 assert(false)
1115 function Text.pos_at_start_of_screen_lineB(State, loc1)
1116 Text.populate_screen_line_starting_pos(State, loc1.line)
1117 local line_cache = State.line_cache[loc1.line]
1118 local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding
1119 Text.populate_screen_line_starting_posB(State, loc1.line, x)
1120 for i=#line_cache.screen_line_starting_posB,1,-1 do
1121 local sposB = line_cache.screen_line_starting_posB[i]
1122 if sposB <= loc1.posB then
1123 return sposB,i
1126 assert(false)
1129 function Text.cursor_at_final_screen_line(State)
1130 Text.populate_screen_line_starting_pos(State, State.cursor1.line)
1131 local line = State.lines[State.cursor1.line]
1132 local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_pos
1133 --? print(screen_lines[#screen_lines], State.cursor1.pos)
1134 if (not State.expanded and not line.expanded) or
1135 line.dataB == nil then
1136 return screen_lines[#screen_lines] <= State.cursor1.pos
1138 if State.cursor1.pos then
1139 -- ignore B side
1140 return screen_lines[#screen_lines] <= State.cursor1.pos
1142 assert(State.cursor1.posB)
1143 local line_cache = State.line_cache[State.cursor1.line]
1144 local x = Margin_left + Text.screen_line_width(State, State.cursor1.line, #line_cache.screen_line_starting_pos) + AB_padding
1145 Text.populate_screen_line_starting_posB(State, State.cursor1.line, x)
1146 local screen_lines = State.line_cache[State.cursor1.line].screen_line_starting_posB
1147 return screen_lines[#screen_lines] <= State.cursor1.posB
1150 function Text.move_cursor_down_to_next_text_line_while_scrolling_again_if_necessary(State)
1151 local y = State.top
1152 while State.cursor1.line <= #State.lines do
1153 if State.lines[State.cursor1.line].mode == 'text' then
1154 break
1156 --? print('cursor skips', State.cursor1.line)
1157 y = y + Drawing_padding_height + Drawing.pixels(State.lines[State.cursor1.line].h, State.width)
1158 State.cursor1.line = State.cursor1.line + 1
1160 -- hack: insert a text line at bottom of file if necessary
1161 if State.cursor1.line > #State.lines then
1162 assert(State.cursor1.line == #State.lines+1)
1163 table.insert(State.lines, {mode='text', data=''})
1164 table.insert(State.line_cache, {})
1166 --? print(y, App.screen.height, App.screen.height-State.line_height)
1167 if y > App.screen.height - State.line_height then
1168 --? print('scroll up')
1169 Text.snap_cursor_to_bottom_of_screen(State)
1173 -- should never modify State.cursor1
1174 function Text.snap_cursor_to_bottom_of_screen(State)
1175 --? print('to2:', State.cursor1.line, State.cursor1.pos, State.cursor1.posB)
1176 local top2 = Text.to2(State, State.cursor1)
1177 --? print('to2: =>', top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB)
1178 -- slide to start of screen line
1179 if top2.screen_pos then
1180 top2.screen_pos = 1
1181 else
1182 assert(top2.screen_posB)
1183 top2.screen_posB = 1
1185 --? print('snap', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB)
1186 --? print('cursor pos '..tostring(State.cursor1.pos)..' is on the #'..tostring(top2.screen_line)..' screen line down')
1187 local y = App.screen.height - State.line_height
1188 -- duplicate some logic from love.draw
1189 while true do
1190 --? print(y, 'top2:', State.lines[top2.line].data, top2.line, top2.screen_line, top2.screen_pos, top2.screen_lineB, top2.screen_posB)
1191 if top2.line == 1 and top2.screen_line == 1 then break end
1192 if top2.screen_line > 1 or State.lines[top2.line-1].mode == 'text' then
1193 local h = State.line_height
1194 if y - h < State.top then
1195 break
1197 y = y - h
1198 else
1199 assert(top2.line > 1)
1200 assert(State.lines[top2.line-1].mode == 'drawing')
1201 -- We currently can't draw partial drawings, so either skip it entirely
1202 -- or not at all.
1203 local h = Drawing_padding_height + Drawing.pixels(State.lines[top2.line-1].h, State.width)
1204 if y - h < State.top then
1205 break
1207 --? print('skipping drawing of height', h)
1208 y = y - h
1210 top2 = Text.previous_screen_line(State, top2)
1212 --? print('top2 finally:', top2.line, top2.screen_line, top2.screen_pos)
1213 State.screen_top1 = Text.to1(State, top2)
1214 --? print('top1 finally:', State.screen_top1.line, State.screen_top1.pos)
1215 --? print('snap =>', State.screen_top1.line, State.screen_top1.pos, State.screen_top1.posB, State.cursor1.line, State.cursor1.pos, State.cursor1.posB, State.screen_bottom1.line, State.screen_bottom1.pos, State.screen_bottom1.posB)
1216 Text.redraw_all(State) -- if we're scrolling, reclaim all fragments to avoid memory leaks
1219 function Text.in_line(State, line_index, x,y)
1220 local line = State.lines[line_index]
1221 local line_cache = State.line_cache[line_index]
1222 if line_cache.starty == nil then return false end -- outside current page
1223 if y < line_cache.starty then return false end
1224 local num_screen_lines = 0
1225 if line_cache.startpos then
1226 Text.populate_screen_line_starting_pos(State, line_index)
1227 num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_pos - Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos) + 1
1229 --? print('#screenlines after A', num_screen_lines)
1230 if line.dataB and (State.expanded or line.expanded) then
1231 local x = Margin_left + Text.screen_line_width(State, line_index, #line_cache.screen_line_starting_pos) + AB_padding
1232 Text.populate_screen_line_starting_posB(State, line_index, x)
1233 --? print('B:', x, #line_cache.screen_line_starting_posB)
1234 if line_cache.startposB then
1235 num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB) -- no +1; first screen line of B side overlaps with A side
1236 else
1237 num_screen_lines = num_screen_lines + #line_cache.screen_line_starting_posB - Text.screen_line_indexB(line_cache.screen_line_starting_posB, 1) -- no +1; first screen line of B side overlaps with A side
1240 --? print('#screenlines after B', num_screen_lines)
1241 return y < line_cache.starty + State.line_height*num_screen_lines
1244 -- convert mx,my in pixels to schema-1 coordinates
1245 -- returns: pos, posB
1246 -- scenarios:
1247 -- line without B side
1248 -- line with B side collapsed
1249 -- line with B side expanded
1250 -- line starting rendering in A side (startpos ~= nil)
1251 -- line starting rendering in B side (startposB ~= nil)
1252 -- my on final screen line of A side
1253 -- mx to right of A side with no B side
1254 -- mx to right of A side but left of B side
1255 -- mx to right of B side
1256 -- preconditions:
1257 -- startpos xor startposB
1258 -- expanded -> dataB
1259 function Text.to_pos_on_line(State, line_index, mx, my)
1260 local line = State.lines[line_index]
1261 local line_cache = State.line_cache[line_index]
1262 assert(my >= line_cache.starty)
1263 -- duplicate some logic from Text.draw
1264 local y = line_cache.starty
1265 --? print('click', line_index, my, 'with line starting at', y, #line_cache.screen_line_starting_pos) -- , #line_cache.screen_line_starting_posB)
1266 if line_cache.startpos then
1267 local start_screen_line_index = Text.screen_line_index(line_cache.screen_line_starting_pos, line_cache.startpos)
1268 for screen_line_index = start_screen_line_index,#line_cache.screen_line_starting_pos do
1269 local screen_line_starting_pos = line_cache.screen_line_starting_pos[screen_line_index]
1270 local screen_line_starting_byte_offset = Text.offset(line.data, screen_line_starting_pos)
1271 --? print('iter', y, screen_line_index, screen_line_starting_pos, string.sub(line.data, screen_line_starting_byte_offset))
1272 local nexty = y + State.line_height
1273 if my < nexty then
1274 -- On all wrapped screen lines but the final one, clicks past end of
1275 -- line position cursor on final character of screen line.
1276 -- (The final screen line positions past end of screen line as always.)
1277 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
1278 --? print('past end of non-final line; return')
1279 return line_cache.screen_line_starting_pos[screen_line_index+1]-1
1281 local s = string.sub(line.data, screen_line_starting_byte_offset)
1282 --? print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_pos + Text.nearest_cursor_pos(s, mx, State.left) - 1)
1283 local screen_line_posA = Text.nearest_cursor_pos(s, mx, State.left)
1284 if line.dataB == nil then
1285 -- no B side
1286 return screen_line_starting_pos + screen_line_posA - 1
1288 if not State.expanded and not line.expanded then
1289 -- B side is not expanded
1290 return screen_line_starting_pos + screen_line_posA - 1
1292 local lenA = utf8.len(s)
1293 if screen_line_posA < lenA then
1294 -- mx is within A side
1295 return screen_line_starting_pos + screen_line_posA - 1
1297 local max_xA = State.left+Text.x(s, lenA+1)
1298 if mx < max_xA + AB_padding then
1299 -- mx is in the space between A and B side
1300 return screen_line_starting_pos + screen_line_posA - 1
1302 mx = mx - max_xA - AB_padding
1303 local screen_line_posB = Text.nearest_cursor_pos(line.dataB, mx, --[[no left margin]] 0)
1304 return nil, screen_line_posB
1306 y = nexty
1309 -- look in screen lines composed entirely of the B side
1310 assert(State.expanded or line.expanded)
1311 local start_screen_line_indexB
1312 if line_cache.startposB then
1313 start_screen_line_indexB = Text.screen_line_indexB(line_cache.screen_line_starting_posB, line_cache.startposB)
1314 else
1315 start_screen_line_indexB = 2 -- skip the first line of side B, which we checked above
1317 for screen_line_indexB = start_screen_line_indexB,#line_cache.screen_line_starting_posB do
1318 local screen_line_starting_posB = line_cache.screen_line_starting_posB[screen_line_indexB]
1319 local screen_line_starting_byte_offsetB = Text.offset(line.dataB, screen_line_starting_posB)
1320 --? print('iter2', y, screen_line_indexB, screen_line_starting_posB, string.sub(line.dataB, screen_line_starting_byte_offsetB))
1321 local nexty = y + State.line_height
1322 if my < nexty then
1323 -- On all wrapped screen lines but the final one, clicks past end of
1324 -- line position cursor on final character of screen line.
1325 -- (The final screen line positions past end of screen line as always.)
1326 --? print('aa', mx, State.left, Text.screen_line_widthB(State, line_index, screen_line_indexB))
1327 if screen_line_indexB < #line_cache.screen_line_starting_posB and mx > State.left + Text.screen_line_widthB(State, line_index, screen_line_indexB) then
1328 --? print('past end of non-final line; return')
1329 return nil, line_cache.screen_line_starting_posB[screen_line_indexB+1]-1
1331 local s = string.sub(line.dataB, screen_line_starting_byte_offsetB)
1332 --? print('return', mx, Text.nearest_cursor_pos(s, mx, State.left), '=>', screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1)
1333 return nil, screen_line_starting_posB + Text.nearest_cursor_pos(s, mx, State.left) - 1
1335 y = nexty
1337 assert(false)
1340 function Text.screen_line_width(State, line_index, i)
1341 local line = State.lines[line_index]
1342 local line_cache = State.line_cache[line_index]
1343 local start_pos = line_cache.screen_line_starting_pos[i]
1344 local start_offset = Text.offset(line.data, start_pos)
1345 local screen_line
1346 if i < #line_cache.screen_line_starting_pos then
1347 local past_end_pos = line_cache.screen_line_starting_pos[i+1]
1348 local past_end_offset = Text.offset(line.data, past_end_pos)
1349 screen_line = string.sub(line.data, start_offset, past_end_offset-1)
1350 else
1351 screen_line = string.sub(line.data, start_pos)
1353 local screen_line_text = App.newText(love.graphics.getFont(), screen_line)
1354 return App.width(screen_line_text)
1357 function Text.screen_line_widthB(State, line_index, i)
1358 local line = State.lines[line_index]
1359 local line_cache = State.line_cache[line_index]
1360 local start_posB = line_cache.screen_line_starting_posB[i]
1361 local start_offsetB = Text.offset(line.dataB, start_posB)
1362 local screen_line
1363 if i < #line_cache.screen_line_starting_posB then
1364 --? print('non-final', i)
1365 local past_end_posB = line_cache.screen_line_starting_posB[i+1]
1366 local past_end_offsetB = Text.offset(line.dataB, past_end_posB)
1367 --? print('between', start_offsetB, past_end_offsetB)
1368 screen_line = string.sub(line.dataB, start_offsetB, past_end_offsetB-1)
1369 else
1370 --? print('final', i)
1371 --? print('after', start_offsetB)
1372 screen_line = string.sub(line.dataB, start_offsetB)
1374 local screen_line_text = App.newText(love.graphics.getFont(), screen_line)
1375 --? local result = App.width(screen_line_text)
1376 --? print('=>', result)
1377 --? return result
1378 return App.width(screen_line_text)
1381 function Text.screen_line_index(screen_line_starting_pos, pos)
1382 for i = #screen_line_starting_pos,1,-1 do
1383 if screen_line_starting_pos[i] <= pos then
1384 return i
1389 function Text.screen_line_indexB(screen_line_starting_posB, posB)
1390 if posB == nil then
1391 return 0
1393 assert(screen_line_starting_posB)
1394 for i = #screen_line_starting_posB,1,-1 do
1395 if screen_line_starting_posB[i] <= posB then
1396 return i
1401 -- convert x pixel coordinate to pos
1402 -- oblivious to wrapping
1403 -- result: 1 to len+1
1404 function Text.nearest_cursor_pos(line, x, left)
1405 if x < left then
1406 return 1
1408 local len = utf8.len(line)
1409 local max_x = left+Text.x(line, len+1)
1410 if x > max_x then
1411 return len+1
1413 local leftpos, rightpos = 1, len+1
1414 --? print('-- nearest', x)
1415 while true do
1416 --? print('nearest', x, '^'..line..'$', leftpos, rightpos)
1417 if leftpos == rightpos then
1418 return leftpos
1420 local curr = math.floor((leftpos+rightpos)/2)
1421 local currxmin = left+Text.x(line, curr)
1422 local currxmax = left+Text.x(line, curr+1)
1423 --? print('nearest', x, leftpos, rightpos, curr, currxmin, currxmax)
1424 if currxmin <= x and x < currxmax then
1425 if x-currxmin < currxmax-x then
1426 return curr
1427 else
1428 return curr+1
1431 if leftpos >= rightpos-1 then
1432 return rightpos
1434 if currxmin > x then
1435 rightpos = curr
1436 else
1437 leftpos = curr
1440 assert(false)
1443 -- return the nearest index of line (in utf8 code points) which lies entirely
1444 -- within x pixels of the left margin
1445 -- result: 0 to len+1
1446 function Text.nearest_pos_less_than(line, x)
1447 --? print('', '-- nearest_pos_less_than', line, x)
1448 local len = utf8.len(line)
1449 local max_x = Text.x_after(line, len)
1450 if x > max_x then
1451 return len+1
1453 local left, right = 0, len+1
1454 while true do
1455 local curr = math.floor((left+right)/2)
1456 local currxmin = Text.x_after(line, curr+1)
1457 local currxmax = Text.x_after(line, curr+2)
1458 --? print('', x, left, right, curr, currxmin, currxmax)
1459 if currxmin <= x and x < currxmax then
1460 return curr
1462 if left >= right-1 then
1463 return left
1465 if currxmin > x then
1466 right = curr
1467 else
1468 left = curr
1471 assert(false)
1474 function Text.x_after(s, pos)
1475 local offset = Text.offset(s, math.min(pos+1, #s+1))
1476 local s_before = s:sub(1, offset-1)
1477 --? print('^'..s_before..'$')
1478 local text_before = App.newText(love.graphics.getFont(), s_before)
1479 return App.width(text_before)
1482 function Text.x(s, pos)
1483 local offset = Text.offset(s, pos)
1484 local s_before = s:sub(1, offset-1)
1485 local text_before = App.newText(love.graphics.getFont(), s_before)
1486 return App.width(text_before)
1489 function Text.to2(State, loc1)
1490 if State.lines[loc1.line].mode == 'drawing' then
1491 return {line=loc1.line, screen_line=1, screen_pos=1}
1493 if loc1.pos then
1494 return Text.to2A(State, loc1)
1495 else
1496 return Text.to2B(State, loc1)
1500 function Text.to2A(State, loc1)
1501 local result = {line=loc1.line}
1502 local line_cache = State.line_cache[loc1.line]
1503 Text.populate_screen_line_starting_pos(State, loc1.line)
1504 for i=#line_cache.screen_line_starting_pos,1,-1 do
1505 local spos = line_cache.screen_line_starting_pos[i]
1506 if spos <= loc1.pos then
1507 result.screen_line = i
1508 result.screen_pos = loc1.pos - spos + 1
1509 break
1512 assert(result.screen_pos)
1513 return result
1516 function Text.to2B(State, loc1)
1517 local result = {line=loc1.line}
1518 local line_cache = State.line_cache[loc1.line]
1519 Text.populate_screen_line_starting_pos(State, loc1.line)
1520 local x = Margin_left + Text.screen_line_width(State, loc1.line, #line_cache.screen_line_starting_pos) + AB_padding
1521 Text.populate_screen_line_starting_posB(State, loc1.line, x)
1522 for i=#line_cache.screen_line_starting_posB,1,-1 do
1523 local sposB = line_cache.screen_line_starting_posB[i]
1524 if sposB <= loc1.posB then
1525 result.screen_lineB = i
1526 result.screen_posB = loc1.posB - sposB + 1
1527 break
1530 assert(result.screen_posB)
1531 return result
1534 function Text.to1(State, loc2)
1535 if loc2.screen_pos then
1536 return Text.to1A(State, loc2)
1537 else
1538 return Text.to1B(State, loc2)
1542 function Text.to1A(State, loc2)
1543 local result = {line=loc2.line, pos=loc2.screen_pos}
1544 if loc2.screen_line > 1 then
1545 result.pos = State.line_cache[loc2.line].screen_line_starting_pos[loc2.screen_line] + loc2.screen_pos - 1
1547 return result
1550 function Text.to1B(State, loc2)
1551 local result = {line=loc2.line, posB=loc2.screen_posB}
1552 if loc2.screen_lineB > 1 then
1553 result.posB = State.line_cache[loc2.line].screen_line_starting_posB[loc2.screen_lineB] + loc2.screen_posB - 1
1555 return result
1558 function Text.lt1(a, b)
1559 if a.line < b.line then
1560 return true
1562 if a.line > b.line then
1563 return false
1565 -- A side < B side
1566 if a.pos and not b.pos then
1567 return true
1569 if not a.pos and b.pos then
1570 return false
1572 if a.pos then
1573 return a.pos < b.pos
1574 else
1575 return a.posB < b.posB
1579 function Text.le1(a, b)
1580 return eq(a, b) or Text.lt1(a, b)
1583 function Text.offset(s, pos1)
1584 if pos1 == 1 then return 1 end
1585 local result = utf8.offset(s, pos1)
1586 if result == nil then
1587 print(pos1, #s, s)
1589 assert(result)
1590 return result
1593 function Text.previous_screen_line(State, loc2)
1594 if loc2.screen_pos then
1595 return Text.previous_screen_lineA(State, loc2)
1596 else
1597 return Text.previous_screen_lineB(State, loc2)
1601 function Text.previous_screen_lineA(State, loc2)
1602 if loc2.screen_line > 1 then
1603 return {line=loc2.line, screen_line=loc2.screen_line-1, screen_pos=1}
1604 elseif loc2.line == 1 then
1605 return loc2
1606 else
1607 Text.populate_screen_line_starting_pos(State, loc2.line-1)
1608 if State.lines[loc2.line-1].dataB == nil or
1609 (not State.expanded and not State.lines[loc2.line-1].expanded) then
1610 --? print('c1', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, State.line_cache[loc2.line-1].fragmentsB)
1611 return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
1613 -- try to switch to B
1614 local prev_line_cache = State.line_cache[loc2.line-1]
1615 local x = Margin_left + Text.screen_line_width(State, loc2.line-1, #prev_line_cache.screen_line_starting_pos) + AB_padding
1616 Text.populate_screen_line_starting_posB(State, loc2.line-1, x)
1617 local screen_line_starting_posB = State.line_cache[loc2.line-1].screen_line_starting_posB
1618 --? print('c', loc2.line-1, State.lines[loc2.line-1].data, '==', State.lines[loc2.line-1].dataB, '==', #screen_line_starting_posB, 'starting from x', x)
1619 if #screen_line_starting_posB > 1 then
1620 --? print('c2')
1621 return {line=loc2.line-1, screen_lineB=#State.line_cache[loc2.line-1].screen_line_starting_posB, screen_posB=1}
1622 else
1623 --? print('c3')
1624 -- if there's only one screen line, assume it overlaps with A, so remain in A
1625 return {line=loc2.line-1, screen_line=#State.line_cache[loc2.line-1].screen_line_starting_pos, screen_pos=1}
1630 function Text.previous_screen_lineB(State, loc2)
1631 if loc2.screen_lineB > 2 then -- first screen line of B side overlaps with A side
1632 return {line=loc2.line, screen_lineB=loc2.screen_lineB-1, screen_posB=1}
1633 else
1634 -- switch to A side
1635 -- TODO: handle case where fold lands precisely at end of a new screen-line
1636 return {line=loc2.line, screen_line=#State.line_cache[loc2.line].screen_line_starting_pos, screen_pos=1}
1640 -- resize helper
1641 function Text.tweak_screen_top_and_cursor(State)
1642 if State.screen_top1.pos == 1 then return end
1643 Text.populate_screen_line_starting_pos(State, State.screen_top1.line)
1644 local line = State.lines[State.screen_top1.line]
1645 local line_cache = State.line_cache[State.screen_top1.line]
1646 for i=2,#line_cache.screen_line_starting_pos do
1647 local pos = line_cache.screen_line_starting_pos[i]
1648 if pos == State.screen_top1.pos then
1649 break
1651 if pos > State.screen_top1.pos then
1652 -- make sure screen top is at start of a screen line
1653 local prev = line_cache.screen_line_starting_pos[i-1]
1654 if State.screen_top1.pos - prev < pos - State.screen_top1.pos then
1655 State.screen_top1.pos = prev
1656 else
1657 State.screen_top1.pos = pos
1659 break
1662 -- make sure cursor is on screen
1663 if Text.lt1(State.cursor1, State.screen_top1) then
1664 State.cursor1 = {line=State.screen_top1.line, pos=State.screen_top1.pos}
1665 elseif State.cursor1.line >= State.screen_bottom1.line then
1666 --? print('too low')
1667 if Text.cursor_out_of_screen(State) then
1668 --? print('tweak')
1669 local pos,posB = Text.to_pos_on_line(State, State.screen_bottom1.line, State.right-5, App.screen.height-5)
1670 State.cursor1 = {line=State.screen_bottom1.line, pos=pos, posB=posB}
1675 -- slightly expensive since it redraws the screen
1676 function Text.cursor_out_of_screen(State)
1677 App.draw()
1678 return State.cursor_y == nil
1679 -- this approach is cheaper and almost works, except on the final screen
1680 -- where file ends above bottom of screen
1681 --? local botpos = Text.pos_at_start_of_screen_line(State, State.cursor1)
1682 --? local botline1 = {line=State.cursor1.line, pos=botpos}
1683 --? return Text.lt1(State.screen_bottom1, botline1)
1686 function source.link_exists(State, filename)
1687 if State.link_cache == nil then
1688 State.link_cache = {}
1690 if State.link_cache[filename] == nil then
1691 State.link_cache[filename] = file_exists(filename)
1693 return State.link_cache[filename]
1696 function Text.redraw_all(State)
1697 --? print('clearing fragments')
1698 State.line_cache = {}
1699 for i=1,#State.lines do
1700 State.line_cache[i] = {}
1702 State.link_cache = {}
1705 function Text.clear_screen_line_cache(State, line_index)
1706 State.line_cache[line_index].fragments = nil
1707 State.line_cache[line_index].fragmentsB = nil
1708 State.line_cache[line_index].screen_line_starting_pos = nil
1709 State.line_cache[line_index].screen_line_starting_posB = nil
1712 function trim(s)
1713 return s:gsub('^%s+', ''):gsub('%s+$', '')
1716 function ltrim(s)
1717 return s:gsub('^%s+', '')
1720 function rtrim(s)
1721 return s:gsub('%s+$', '')
1724 function starts_with(s, sub)
1725 return s:find(sub, 1, --[[no escapes]] true) == 1
1728 function ends_with(s, sub)
1729 return s:reverse():find(sub:reverse(), 1, --[[no escapes]] true) == 1