Template repo for tiny cross-platform apps that can be modified on phone, tablet or computer.
1local utf8 = require 'utf8'
2
3local array = require 'array'
4local my_utf8 = require 'my_utf8'
5local t = require 'utils'
6local rects = require 'rects'
7local Loc = require 'loc'
8local move = require 'move'
9local keychord = require 'keychord'
10
11local edit = {}
12
13local I = {} -- for file-local helpers, so I can refer to them before defining them
14edit.internal = I
15local _ -- idiom for unused variables
16
17-- some constants people might like to tweak
18Cursor_color = {1, 0, 0} -- available to app; indexed by position rather than r/g/b to work with select_rgb
19local Highlight_color = {0.7, 0.7, 0.9, 0.4} -- selected text
20
21Line_number_width = 3 -- in ems; available to app
22local Line_number_color = {0.4, 0.4, 0.4}
23
24local Font_height = require 'font_height'
25
26Hand_icon = love.mouse.getSystemCursor('hand')
27Arrow_icon = love.mouse.getSystemCursor('arrow')
28
29function edit.is_this_love_version_supported()
30 local supported_versions = {
31 ['11.5']=true,
32 ['11.4']=true,
33 ['11.3']=true,
34 ['11.2']=true,
35 ['11.1']=true,
36 ['11.0']=true,
37 ['12.0']=true,
38 }
39 return supported_versions[edit.love_version()]
40end
41
42function edit.preferred_love_version()
43 return '11.5'
44end
45
46function edit.love_version()
47 local major_version, minor_version = love.getVersion()
48 return major_version..'.'..minor_version
49end
50
51local Last_focus_time, Last_resize_time
52Cursor_time = 0 -- available to app
53
54-- initialize editor
55-- this should happen only once regardless of how many editors you create
56function edit.load()
57 -- for hysteresis in a few places
58 Last_focus_time = 0 -- https://love2d.org/forums/viewtopic.php?p=249700
59 Last_resize_time = 0
60
61 -- blinking cursor (I assume you never show the cursor in more than one editor)
62 Cursor_time = 0
63end
64
65function edit.new_from_defaults(top, left, right, bottom)
66 local font = love.graphics.newFont(Font_height)
67 return edit.new(top, left, right, bottom, font, Font_height)
68end
69
70function edit.new_from_settings(settings, top, left, right, bottom)
71 local font = love.graphics.newFont(settings.font_height)
72 local editor = edit.new(top, left, right, bottom, font, settings.font_height)
73 edit.load_file(editor, settings.filename)
74 editor.screen_top = settings.screen_top
75 editor.cursor = settings.cursor
76 if not I.valid_loc(editor, editor.screen_top)
77 or not I.valid_loc(editor, editor.cursor) then
78 I.scroll_to_top(editor)
79 end
80 return editor
81end
82
83function edit.resize(editor, w, h, right, bottom)
84 editor.right = right
85 editor.width = editor.right-editor.left
86 editor.bottom = bottom
87 Last_resize_time = Current_time
88end
89
90function edit.settings(editor)
91 return {
92 font_height=editor.font_height,
93 filename=editor.filename,
94 screen_top=editor.screen_top, cursor=editor.cursor
95 }
96end
97
98function edit.load_file(editor, filename)
99 editor.filename = filename
100 editor.lines = {}
101 if love.filesystem.getInfo(filename) then
102 for line in love.filesystem.lines(editor.filename) do
103 table.insert(editor.lines, {data=line})
104 end
105 end
106 if #editor.lines == 0 then
107 table.insert(editor.lines, {data=''})
108 end
109 I.scroll_to_top(editor) -- clear out stale locations
110 colorize.all(editor)
111end
112
113function edit.load_array(editor, lines)
114 editor.lines = {}
115 assert(#lines > 0)
116 for _, line in ipairs(lines) do
117 table.insert(editor.lines, {data=line})
118 end
119 colorize.all(editor)
120end
121
122function edit.focus(in_focus)
123 if in_focus then
124 Last_focus_time = Current_time
125 end
126end
127
128function edit.update_all(dt)
129 Current_time = Current_time + dt
130 Cursor_time = Cursor_time + dt
131end
132
133function edit.new(top, left, right, bottom, font, font_height)
134 local result = {
135 top = math.floor(top),
136 left = math.floor(left),
137 right = math.floor(right),
138 bottom = math.floor(bottom),
139 width = right-left,
140
141 font = font,
142 font_height = font_height,
143 line_height = math.floor(font_height*1.3),
144
145 indent_wrapped_lines = nil,
146
147 -- The editor is for editing an array of lines.
148 -- The array of lines can never be empty; there must be at least one line for positioning a cursor at.
149 lines = {{data=''}}, -- array of strings
150
151 -- We need to track a couple of _locations_:
152 screen_top = {line=1, pos=1}, -- location at top of screen, to start drawing from
153 cursor = {line=1, pos=1}, -- location where editing will occur
154
155 selection = {},
156
157 filename = love.filesystem.getSourceBaseDirectory()..'/lines.txt', -- '/' should work even on Windows
158 modified = nil,
159
160 -- undo
161 history = {},
162 next_history = 1,
163
164 -- search
165 search_term = nil,
166 search_backup = nil, -- stuff to restore when cancelling search
167
168 -- touch features
169 current_touch = nil,
170 inertial_scroll = {},
171 }
172 return result
173end
174
175function edit.draw(editor, fg, hide_cursor, show_line_numbers, cursor_color)
176 if fg and fg.r then fg = {fg.r, fg.g, fg.b} end -- old interface for compatibility
177 local y = editor.top
178 for line_index = editor.screen_top.line, #editor.lines do
179 local loc
180 assert(editor.lines[line_index].data, 'each line in editor should contain a field called .data containing a string (use edit.load_array instead of setting edit.lines directly)')
181 if line_index == editor.screen_top.line then
182 loc = editor.screen_top
183 else
184 loc = {line=line_index, pos=1}
185 end
186 local rect = rects.compute(editor, loc, editor.bottom - y)
187 if show_line_numbers and loc.pos == 1 then
188 local w = Line_number_width * editor.font:getWidth('m')
189 love.graphics.setColor(Line_number_color)
190 love.graphics.print(loc.line, editor.left-w+10, y)
191 end
192 for _,s in ipairs(rect.screen_line_rects) do
193--? love.graphics.setColor(0.8, 0.8, 0.8)
194--? love.graphics.rectangle('fill', editor.left+s.x,y+s.y, s.dx,s.dy)
195 for _,c in ipairs(s.char_rects) do
196--? love.graphics.setColor(0.8, 0.9, 0.8)
197--? love.graphics.rectangle('fill', editor.left+c.x,y+c.y, c.dx-2,c.dy-1)
198 if c.show_cursor then -- only the primary place where the cursor can be is available for highlight
199 if I.in_selection(editor, line_index, c.pos, editor.cursor)
200 or I.in_search(editor, line_index, c.pos)
201 then
202 love.graphics.setColor(Highlight_color)
203 love.graphics.rectangle('fill', editor.left+c.x, y+c.y, c.dx,c.dy)
204 end
205 end
206 if c.draw then
207 for _,s in ipairs(c.draw) do I.draw_shape(s, editor.left, y, fg) end
208 end
209 if c.data and not c.conceal then
210 if fg == nil then
211 if c.pos then
212 love.graphics.setColor(c.fg or editor.colors[line_index][c.pos])
213 else
214 love.graphics.setColor(c.fg)
215 end
216 else
217 love.graphics.setColor(c.fg or fg)
218 end
219 love.graphics.print(c.data, editor.left+c.x, y+c.y)
220 end
221 if not hide_cursor and line_index == editor.cursor.line then
222 if c.pos == editor.cursor.pos and c.show_cursor then
223 I.draw_text_cursor(editor, editor.left+c.x, y+c.y, c.dy, cursor_color)
224 end
225 end
226 end
227 end
228 y = y + rect.dy
229 if y + editor.line_height > editor.bottom then
230 break
231 end
232 end
233 I.draw_selection_handles(editor)
234 if editor.search_term then
235 I.draw_search_bar(editor)
236 end
237end
238
239-- scenarios:
240-- shift down, mouse click -> select text
241-- shift up, mouse click -> move cursor
242-- shift down, mouse click, mouse click -> select text from cursor at start to cursor at end
243-- mouse down, shift down, mouse up -> select text from mouse press to mouse release
244-- drag -> select text from mouse press to mouse release
245-- It looks like all this can be handled by setting selection on press and
246-- cursor while mouse button is down.
247function edit.mouse_press(editor, mx,my, mouse_button, is_touch, presses)
248 if is_touch then return end
249 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
250 if mouse_button ~= 1 then return end
251 if editor.search_term then return end
252 if keychord.shift_down() then
253 if editor.selection.line == nil then
254 -- mouse down, shift down; set selection to old cursor
255 editor.selection = editor.cursor
256 end
257 else
258 -- mouse down, shift up: set selection to current cursor
259 editor.selection = move.to_loc(editor, mx,my)
260 end
261 editor.cursor = move.to_loc(editor, mx,my)
262end
263
264function edit.mouse_move(editor, mx,my, dx,dy, is_touch)
265 if is_touch then return end
266 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
267 if love.mouse.isDown(1) then
268 editor.cursor = move.to_loc(editor, mx,my)
269 end
270end
271
272function edit.mouse_release(editor, mx,my, mouse_button, is_touch, presses)
273 if is_touch then return end
274 if Loc.eq(editor.cursor, editor.selection) then
275 editor.selection = {}
276 end
277end
278
279Device_has_touch = false
280
281function edit.touch_press(editor, touch_id, tx,ty, dx,dy, pressure)
282 Device_has_touch = true
283 if editor.search_term then return end
284 if editor.current_touch then return end -- no multi-touch support so far
285 if editor.selection.line then
286 if I.on_selection_handle(editor, editor.selection, tx,ty) then
287 editor.current_touch = {
288 id=touch_id,
289 on_selection=true,
290 }
291 elseif I.on_selection_handle(editor, editor.cursor, tx,ty) then
292 editor.current_touch = {
293 id=touch_id,
294 on_cursor=true,
295 }
296 else
297 -- touch outside handles
298 editor.selection = {}
299 end
300 return
301 end
302 editor.current_touch = {
303 id=touch_id,
304 start_time=Current_time,
305 start_x=tx,
306 start_y=ty,
307 prev_x=nil,
308 prev_y=nil,
309 current_x=tx,
310 current_y=ty,
311 has_moved=false,
312 prev_move_time=nil,
313 }
314end
315
316function edit.touch_move(editor, touch_id, tx,ty, dx,dy, pressure)
317 if not editor.current_touch then return end
318 if touch_id ~= editor.current_touch.id then return end
319 local touch = editor.current_touch
320 if touch.on_selection then
321 I.move_selection_handle(editor, editor.selection, tx, ty)
322 return
323 elseif touch.on_cursor then
324 I.move_selection_handle(editor, editor.cursor, tx, ty)
325 return
326 end
327 touch.prev_x = touch.current_x or touch.start_x
328 touch.prev_y = touch.current_y or touch.start_y
329 touch.current_x = tx
330 touch.current_y = ty
331 local distance_sq = (tx-touch.start_x)^2 + (ty-touch.start_y)^2
332 if distance_sq > 10*10 then
333 touch.has_moved = true
334 local time_delta = Current_time - (touch.prev_move_time or touch.start_time)
335 if time_delta > 0 then
336 touch.velocity_y = -(ty - touch.prev_y) / time_delta
337 end
338 touch.prev_move_time = Current_time
339 -- scroll while dragging
340 local scroll_dy = -(ty - touch.prev_y) * I.inertial_scroll_sensitivity
341 I.apply_scroll(editor, scroll_dy)
342 end
343end
344
345function edit.touch_release(editor, touch_id, tx,ty, dx,dy, pressure)
346 if not editor.current_touch then return end
347 if touch_id == editor.current_touch.id then
348 local touch = editor.current_touch
349 if touch.on_selection or touch.on_cursor then
350 -- ensure selection is at start and cursor at end for next touch
351 assert(editor.selection)
352 if Loc.lt(editor.cursor, editor.selection) then
353 editor.selection, editor.cursor = editor.cursor, editor.selection
354 I.init_selection_handle(editor, editor.selection, -1)
355 I.init_selection_handle(editor, editor.cursor, 0)
356 end
357 elseif not touch.has_moved then
358 love.keyboard.setTextInput(true)
359 local touch_duration = Current_time - touch.start_time
360 if touch_duration < 0.3 then
361 editor.cursor = move.to_loc(editor, touch.start_x, touch.start_y)
362 editor.selection = {}
363 else
364 -- long touch: go into selection mode
365 I.start_text_selection(editor, touch.start_x, touch.start_y)
366 end
367 else
368 -- drag: scroll with inertial momentum
369 I.start_inertial_scroll(editor, touch.velocity_y)
370 end
371 end
372 editor.current_touch = nil
373end
374
375-- inertial scroll
376I.inertial_scroll_sensitivity = 1.0
377I.inertial_scroll_velocity_threshold = 10
378I.inertial_scroll_velocity_multiplier = 1.0
379I.inertial_scroll_deceleration = 450
380
381function I.start_inertial_scroll(editor, velocity_y)
382 if math.abs(velocity_y) > I.inertial_scroll_velocity_threshold then
383 editor.inertial_scroll = {
384 active = true,
385 velocity_y = velocity_y * I.inertial_scroll_velocity_multiplier,
386 accumulator = nil,
387 }
388 end
389end
390
391function edit.update_inertial_scroll(editor, dt)
392 local s = editor.inertial_scroll
393 if not s.active then
394 return
395 end
396 I.apply_scroll(editor, s.velocity_y*dt)
397 -- decelerate
398 if math.abs(s.velocity_y) < I.inertial_scroll_deceleration*dt then -- s.velocity_y about to switch sign
399 editor.inertial_scroll = {}
400 return
401 end
402 local velocity_sign = (s.velocity_y > 0 and 1 or -1)
403 local decel = velocity_sign * I.inertial_scroll_deceleration
404 s.velocity_y = s.velocity_y - decel*dt
405 if math.abs(s.velocity_y) < 1 then
406 editor.inertial_scroll = {}
407 return
408 end
409end
410
411function I.apply_scroll(editor, dy)
412 if dy == 0 then return end
413 local s = editor.inertial_scroll
414 if s.accumulator == nil then s.accumulator = 0 end
415 s.accumulator = s.accumulator + dy
416 local lines_to_scroll = math.floor(math.abs(s.accumulator) / editor.line_height)
417 if lines_to_scroll == 0 then return end
418 local direction = s.accumulator > 0 and 1 or -1
419 local scroll_fn
420 if direction > 0 then
421 scroll_fn = move.internal.loc_down
422 else
423 scroll_fn = move.internal.loc_up
424 end
425 local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height)
426 if tmp then
427 editor.screen_top = tmp
428 editor.cursor = t.deepcopy(editor.screen_top)
429 s.accumulator = s.accumulator - direction*lines_to_scroll*editor.line_height
430 else
431 error('bug: too fast')
432 end
433end
434
435function edit.maybe_stop_inertial_scroll(editor)
436 if editor.current_touch then return false end -- current touch is in progress
437 if not editor.inertial_scroll.active then return false end
438 editor.inertial_scroll = {}
439 return true
440end
441
442-- Text selection with draggable handles
443function I.start_text_selection(editor, touch_x, touch_y)
444 local loc = move.to_loc(editor, touch_x, touch_y)
445 if not loc then return end
446 local curr_line = editor.lines[loc.line].data
447 local word_start, word_end = I.find_word_boundaries(curr_line, loc.pos)
448 editor.selection = {line = loc.line, pos = word_start}
449 editor.cursor = {line = loc.line, pos = word_end}
450 I.init_selection_handle(editor, editor.selection, -1)
451 I.init_selection_handle(editor, editor.cursor, 0)
452end
453
454function I.find_word_boundaries(s, pos)
455 assert(pos <= utf8.len(s)+1)
456 local word_start, word_end = pos, pos
457 while true do
458 assert(word_start >= 1)
459 if word_start == 1 then
460 break
461 end
462 if my_utf8.match_at(s, word_start-1, '%s') then
463 break
464 end
465 word_start = word_start-1
466 end
467 while true do
468 assert(word_end <= utf8.len(s)+1)
469 if word_end > utf8.len(s) then
470 break
471 end
472 if my_utf8.match_at(s, word_end, '%s') then
473 break
474 end
475 word_end = word_end+1
476 end
477 return word_start, word_end
478end
479
480function I.init_selection_handle(editor, loc, xoff)
481 local x,y = move.to_coord(editor, loc)
482 if not x then
483 loc.handle = nil
484 loc.area = nil
485 else
486 local em_width = editor.font:getWidth('m')
487 loc.handle = {x=x+xoff*em_width, y=y+editor.line_height, dx=em_width, dy=editor.line_height, xoff=xoff}
488 loc.area = {x=x+(xoff-1)*em_width, y=y, dx=3*em_width, dy=3*editor.line_height, xoff=xoff}
489 end
490end
491
492function I.draw_selection_handles(editor)
493 if not Device_has_touch then return end
494 if not editor.selection.line then return end
495 local r = editor.selection.handle
496 if r then
497 love.graphics.setColor(0.6, 0.6, 0.7)
498 love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy)
499 end
500 r = editor.cursor.handle
501 if r then
502 love.graphics.setColor(0.6, 0.6, 0.7)
503 love.graphics.rectangle('fill', r.x,r.y, r.dx,r.dy)
504 end
505 if editor.current_touch then
506 if editor.current_touch.on_selection then
507 I.draw_magnifier(editor, editor.selection)
508 elseif editor.current_touch.on_cursor then
509 I.draw_magnifier(editor, editor.cursor)
510 end
511 end
512end
513
514function I.on_selection_handle(editor, loc, x,y)
515 if not loc.area then return false end
516 return move.internal.within_rect(loc.area, x,y)
517end
518
519-- modify loc in place to x,y
520function I.move_selection_handle(editor, loc, x,y)
521 I.scroll_with_selection_handle(editor, loc, x,y)
522 -- Get screen coordinates for handles
523 local nloc = move.to_loc(editor, x,y)
524 if not nloc then return end
525 I.init_selection_handle(editor, nloc, loc.handle.xoff)
526 loc.line, loc.pos, loc.handle, loc.area = nloc.line, nloc.pos, nloc.handle, nloc.area
527end
528
529-- Scroll if x,y is close to top or bottom margins
530function I.scroll_with_selection_handle(editor, loc, x,y)
531 local zone1 = 30 -- px
532 local within_zone1_speed = 1 -- lines/frame
533 local outside_zone1_speed = 2 -- lines/frame
534 local scroll_speed = 0
535 assert(editor.top < editor.bottom)
536 if y < editor.top-zone1 then
537 scroll_speed = -outside_zone1_speed
538 elseif y < editor.top then
539 scroll_speed = -within_zone1_speed
540 elseif y < editor.bottom then
541 loc.scroll_accumulator = nil
542 return
543 elseif y < editor.bottom+zone1 then
544 scroll_speed = within_zone1_speed
545 else
546 assert(y >= editor.bottom+zone1)
547 scroll_speed = outside_zone1_speed
548 end
549 -- TODO: dedup this next bit with I.apply_scroll
550 if loc.scroll_accumulator == nil then
551 loc.scroll_accumulator = 0
552 end
553 loc.scroll_accumulator = loc.scroll_accumulator + scroll_speed
554 local lines_to_scroll = math.floor(math.abs(loc.scroll_accumulator) / editor.line_height)
555 if lines_to_scroll == 0 then return end
556 local direction = scroll_speed > 0 and 1 or -1
557 local scroll_fn
558 if direction > 0 then
559 scroll_fn = move.internal.loc_down
560 else
561 scroll_fn = move.internal.loc_up
562 end
563 local tmp = scroll_fn(editor, editor.screen_top, lines_to_scroll*editor.line_height)
564 if tmp then
565 editor.screen_top = tmp
566 loc.scroll_accumulator = loc.scroll_accumulator - direction*lines_to_scroll*editor.line_height
567 -- update the other handle
568 if loc == editor.cursor then
569 I.init_selection_handle(editor, editor.selection, -1)
570 elseif loc == editor.selection then
571 I.init_selection_handle(editor, editor.cursor, 0)
572 else
573 error('invalid loc')
574 end
575 else
576 error('bug: too fast')
577 end
578end
579
580-- magnify the area around loc
581-- precondition: loc.handle exists
582function I.draw_magnifier(editor, loc)
583 local zoom_factor = 2.0
584 local mag_offset = 60
585 local mag_height = editor.line_height*zoom_factor
586 local line_text = editor.lines[loc.line].data
587 local chars_around = 5
588 local start_pos = math.max(1, loc.pos - chars_around)
589 local end_pos = math.min(utf8.len(line_text), loc.pos + chars_around)
590 local mag_text = my_utf8.sub(line_text, start_pos, end_pos+1)
591 local mag_text_width = editor.font:getWidth(mag_text)
592 local mag_width = mag_text_width*zoom_factor
593 -- position magnifier well to the left, for two reasons:
594 -- - assume a right-handed touch
595 -- - avoid the scrollbar on the right
596 local mag_x = editor.left
597 -- position magnifier above if possible, or below
598 local mag_y = loc.handle.y - mag_height - mag_offset
599 if mag_y < editor.top then
600 mag_y = loc.handle.y + mag_offset
601 end
602 -- draw magnifier frame and content
603 love.graphics.setColor(0,0,0)
604 love.graphics.setLineWidth(2)
605 love.graphics.rectangle('fill', mag_x-5, mag_y-5, mag_width+10, mag_height+10, 5,5)
606 love.graphics.setLineWidth(1)
607 love.graphics.setColor(1,1,1)
608 love.graphics.rectangle('fill', mag_x-2, mag_y-2, mag_width+4, mag_height+4)
609 if Loc.lt(editor.cursor, loc) or Loc.lt(editor.selection, loc) then
610 -- loc is at end of selection
611 local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height)
612 I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height)
613 elseif Loc.lt(loc, editor.cursor) or Loc.lt(loc, editor.selection) then
614 -- loc is at start of selection
615 local x = I.draw_magnifier_text(line_text, start_pos, loc.pos, mag_x, mag_y, editor.font, {1,1,1}, zoom_factor, editor.line_height)
616 I.draw_magnifier_text(line_text, loc.pos, end_pos+1, x, mag_y, editor.font, Highlight_color, zoom_factor, editor.line_height)
617 end
618end
619
620function I.draw_magnifier_text(s, start_pos, end_pos, x,y, font, background_color, zoom_factor, line_height)
621 local text = my_utf8.sub(s, start_pos, end_pos)
622 local w = font:getWidth(text)*zoom_factor
623 love.graphics.setColor(background_color)
624 love.graphics.rectangle('fill', x,y, w, line_height*zoom_factor)
625 love.graphics.setColor(0,0,0)
626 love.graphics.print(text, x, y, --[[rotation]] 0, zoom_factor)
627 return x+w
628end
629
630function edit.mouse_wheel_move(editor, dx,dy)
631 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
632 if dy > 0 then
633 editor.cursor = t.deepcopy(editor.screen_top)
634 for i=1,math.floor(dy) do
635 move.up_arrow(editor)
636 end
637 elseif dy < 0 then
638 editor.cursor = move.to_loc(editor, editor.left, editor.bottom-1)
639 assert(editor.cursor)
640 for i=1,math.floor(-dy) do
641 move.down_arrow(editor)
642 end
643 end
644end
645
646function I.scroll_to_top(editor)
647 assert(#editor.lines > 0)
648 editor.screen_top = {line=1, pos=1}
649 editor.cursor = {line=1, pos=1}
650end
651
652function I.valid_loc(editor, loc)
653 assert(#editor.lines > 0)
654 if loc == nil then return end
655 if loc.line > #editor.lines then return end
656 if loc.pos > #editor.lines[loc.line].data then return end
657 return true
658end
659
660function I.mark_modified(editor)
661 editor.modified = true
662end
663
664I.buffer_modified_handlers = {}
665
666function edit.signal_buffer_modified(editor)
667 for _, handler in ipairs(I.buffer_modified_handlers) do
668 handler(editor)
669 end
670end
671
672table.insert(I.buffer_modified_handlers, I.mark_modified)
673table.insert(I.buffer_modified_handlers, colorize.all)
674
675---- keyboard handling
676
677function edit.text_input(editor, t, readonly)
678 -- ignore events for some time after window in focus (mostly alt-tab)
679 if Current_time < Last_focus_time + 0.01 then
680 return
681 end
682 --
683 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
684 if love.mouse.isDown(1) then return end
685 if editor.search_term then
686 editor.search_term = editor.search_term..t
687 I.search_next(editor)
688 return
689 end
690 if move.to_coord(editor, editor.cursor) == nil then return end -- cursor is off screen
691 if keychord.any_modifier_down() and key_down(t) then
692 -- The modifiers didn't change the key. Handle it in keychord_press.
693 return
694 end
695 if not readonly then
696 local before = I.snapshot(editor, editor.cursor.line)
697 -- Try to support mobile keyboard swipe/autocomplete/paste as best we
698 -- can.
699 --
700 -- Mobile paste still works poorly. It's unreliable, often truncates
701 -- large pastes, and inserts newlines twice, first keypress events for
702 -- all characters in a paste and _then_ textinput for all characters.
703 I.insert_text(editor, t)
704 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)})
705 edit.signal_buffer_modified(editor)
706 end
707end
708
709function edit.keychord_press(editor, chord, key, scancode, is_repeat, readonly)
710 -- ignore events for some time after window in focus (mostly alt-tab)
711 if Current_time < Last_focus_time + 0.01 then
712 return
713 end
714 --
715 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
716 local cursor_on_screen = move.to_coord(editor, editor.cursor)
717 local dm = keychord.default_modifier
718 if not readonly and editor.selection.line and
719 cursor_on_screen and
720 -- printable character created using shift key => delete selection
721 -- (we're not creating any ctrl-shift- or alt-shift- combinations using regular/printable keys)
722 (not keychord.shift_down() or utf8.len(key) == 1) and
723 chord ~= dm('a') and chord ~= dm('c') and chord ~= dm('x') and chord ~= 'backspace' and chord ~= 'delete' and chord ~= dm('z') and chord ~= dm('y') and not keychord.is_cursor_movement(key) then
724 I.delete_selection_and_record_undo_event(editor)
725 edit.signal_buffer_modified(editor) -- possibly redundant, but ensure it happens
726 end
727 if editor.search_term then
728 if chord == 'escape' then
729 editor.search_term = nil
730 editor.cursor = editor.search_backup.cursor
731 editor.screen_top = editor.search_backup.screen_top
732 editor.search_backup = nil
733 elseif chord == 'return' then
734 editor.search_term = nil
735 editor.search_backup = nil
736 elseif chord == 'backspace' then
737 local len = utf8.len(editor.search_term)
738 local byte_offset = my_utf8.offset(editor.search_term, len)
739 editor.search_term = string.sub(editor.search_term, 1, byte_offset-1)
740 editor.cursor = t.deepcopy(editor.search_backup.cursor)
741 editor.screen_top = t.deepcopy(editor.search_backup.screen_top)
742 I.search_next(editor)
743 elseif chord == 'down' then
744 if #editor.search_term > 0 then
745 move.right_arrow(editor)
746 I.search_next(editor)
747 end
748 elseif chord == 'up' then
749 I.search_previous(editor)
750 end
751 return
752 elseif chord == dm('f') then
753 editor.search_term = ''
754 editor.search_backup = {
755 cursor=t.deepcopy(editor.cursor),
756 screen_top=t.deepcopy(editor.screen_top),
757 }
758 -- zoom
759 elseif chord == dm('=') then
760 edit.update_font_settings(editor, editor.font_height+2)
761 elseif chord == dm('-') then
762 if editor.font_height > 2 then
763 edit.update_font_settings(editor, editor.font_height-2)
764 end
765 elseif chord == dm('0') then
766 edit.update_font_settings(editor, 20)
767 -- undo
768 elseif not readonly and chord == dm('z') then
769 local event = I.undo_event(editor)
770 if event then
771 local src = event.before
772 editor.screen_top = t.deepcopy(src.screen_top)
773 editor.cursor = t.deepcopy(src.cursor)
774 editor.selection = t.deepcopy(src.selection)
775 I.patch(editor.lines, event.after, event.before)
776 edit.signal_buffer_modified(editor)
777 end
778 elseif not readonly and chord == dm('y') then
779 local event = I.redo_event(editor)
780 if event then
781 local src = event.after
782 editor.screen_top = t.deepcopy(src.screen_top)
783 editor.cursor = t.deepcopy(src.cursor)
784 editor.selection = t.deepcopy(src.selection)
785 I.patch(editor.lines, event.before, event.after)
786 edit.signal_buffer_modified(editor)
787 end
788 -- clipboard
789 elseif chord == dm('a') and cursor_on_screen then
790 editor.selection = {line=1, pos=1}
791 editor.cursor = {line=#editor.lines, pos=utf8.len(editor.lines[#editor.lines].data)+1}
792 elseif chord == dm('c') then
793 local s = I.selection(editor)
794 if s then
795 love.system.setClipboardText(s)
796 end
797 elseif not readonly and chord == dm('x') and cursor_on_screen then
798 local s = I.cut_selection(editor, editor.left, editor.right)
799 if s then
800 love.system.setClipboardText(s)
801 end
802 edit.signal_buffer_modified(editor)
803 elseif not readonly and chord == dm('v') and cursor_on_screen then
804 local before_line = editor.cursor.line
805 local before = I.snapshot(editor, before_line)
806 local clipboard_data = love.system.getClipboardText()
807 I.insert_text(editor, clipboard_data)
808 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)})
809 edit.signal_buffer_modified(editor)
810 --== shortcuts that mutate text
811 elseif not readonly and chord == 'return' and cursor_on_screen then
812 local before_line = editor.cursor.line
813 local before = I.snapshot(editor, before_line)
814 I.insert_return_at_cursor(editor)
815 move.maybe_snap_cursor_to_bottom_of_screen(editor)
816 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, before_line, editor.cursor.line)})
817 edit.signal_buffer_modified(editor)
818 elseif not readonly and chord == 'tab' and cursor_on_screen then
819 local before = I.snapshot(editor, editor.cursor.line)
820 I.insert_char_at_cursor(editor, '\t')
821 move.maybe_snap_cursor_to_bottom_of_screen(editor)
822 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)})
823 edit.signal_buffer_modified(editor)
824 elseif not readonly and chord == 'backspace' and cursor_on_screen then
825 if editor.selection.line then
826 I.delete_selection_and_record_undo_event(editor)
827 edit.signal_buffer_modified(editor)
828 return
829 end
830 if editor.cursor.line == 1 and editor.cursor.pos == 1 then
831 -- no-op
832 return
833 end
834 local before
835 if editor.cursor.pos > 1 then
836 before = I.snapshot(editor, editor.cursor.line)
837 local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos-1)
838 local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos)
839 if byte_start then
840 if byte_end then
841 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end)
842 else
843 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)
844 end
845 editor.cursor.pos = editor.cursor.pos-1
846 end
847 else
848 assert(editor.cursor.line > 1)
849 before = I.snapshot(editor, editor.cursor.line-1, editor.cursor.line)
850 -- join lines
851 editor.cursor.pos = utf8.len(editor.lines[editor.cursor.line-1].data)+1
852 editor.lines[editor.cursor.line-1].data = editor.lines[editor.cursor.line-1].data..editor.lines[editor.cursor.line].data
853 table.remove(editor.lines, editor.cursor.line)
854 editor.cursor.line = editor.cursor.line-1
855 end
856 if editor.screen_top.line > #editor.lines then
857 -- line no longer exists
858 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left)
859 elseif Loc.eq(editor.cursor, editor.screen_top) then
860 local len = utf8.len(editor.lines[editor.cursor.line].data)
861 assert(editor.cursor.pos <= len+1)
862 if editor.cursor.pos == len+1 then
863 -- screen line may no longer exist
864 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left)
865 assert(editor.screen_top)
866 end
867 elseif Loc.lt(editor.cursor, editor.screen_top) then
868 move.maybe_snap_cursor_to_top_of_screen(editor)
869 end
870 assert(Loc.le(editor.screen_top, editor.cursor), ('screen_top (line=%d,pos=%d) is below cursor (line=%d,pos=%d)'):format(editor.screen_top.line, editor.screen_top.pos or -1, editor.cursor.line, editor.cursor.pos or -1))
871 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)})
872 edit.signal_buffer_modified(editor)
873 elseif not readonly and chord == 'delete' and cursor_on_screen then
874 -- cursor in text line
875 if editor.selection.line then
876 I.delete_selection_and_record_undo_event(editor)
877 edit.signal_buffer_modified(editor)
878 return
879 end
880 local before
881 if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then
882 before = I.snapshot(editor, editor.cursor.line)
883 else
884 before = I.snapshot(editor, editor.cursor.line, editor.cursor.line+1)
885 end
886 if editor.cursor.pos <= utf8.len(editor.lines[editor.cursor.line].data) then
887 local byte_start = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos)
888 local byte_end = utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos+1)
889 if byte_start then
890 if byte_end then
891 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)..string.sub(editor.lines[editor.cursor.line].data, byte_end)
892 else
893 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_start-1)
894 end
895 -- no change to editor.cursor.pos
896 end
897 elseif editor.cursor.line < #editor.lines then
898 -- join lines
899 editor.lines[editor.cursor.line].data = editor.lines[editor.cursor.line].data..editor.lines[editor.cursor.line+1].data
900 table.remove(editor.lines, editor.cursor.line+1)
901 end
902 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)})
903 edit.signal_buffer_modified(editor)
904 --== shortcuts that move the cursor
905 elseif chord == 'left' and cursor_on_screen then
906 move.left_arrow(editor)
907 editor.selection = {}
908 elseif chord == 'right' and cursor_on_screen then
909 move.right_arrow(editor)
910 editor.selection = {}
911 elseif chord == 'S-left' and cursor_on_screen then
912 if editor.selection.line == nil then
913 editor.selection = t.deepcopy(editor.cursor)
914 end
915 move.left_arrow(editor)
916 elseif chord == 'S-right' and cursor_on_screen then
917 if editor.selection.line == nil then
918 editor.selection = t.deepcopy(editor.cursor)
919 end
920 move.right_arrow(editor)
921 -- C- hotkeys reserved for drawings, so we'll use M-
922 elseif chord == 'M-left' and cursor_on_screen then
923 move.word_left(editor)
924 editor.selection = {}
925 elseif chord == 'M-right' and cursor_on_screen then
926 move.word_right(editor)
927 editor.selection = {}
928 elseif chord == 'M-S-left' and cursor_on_screen then
929 if editor.selection.line == nil then
930 editor.selection = t.deepcopy(editor.cursor)
931 end
932 move.word_left(editor)
933 elseif chord == 'M-S-right' and cursor_on_screen then
934 if editor.selection.line == nil then
935 editor.selection = t.deepcopy(editor.cursor)
936 end
937 move.word_right(editor)
938 elseif (chord == 'home' or (OS == 'OS X' and chord == 's-left')) and cursor_on_screen then
939 move.start_of_line(editor)
940 editor.selection = {}
941 elseif (chord == 'end' or (OS == 'OS X' and chord == 's-right')) and cursor_on_screen then
942 move.end_of_line(editor)
943 editor.selection = {}
944 elseif chord == 'S-home' and cursor_on_screen then
945 if editor.selection.line == nil then
946 editor.selection = t.deepcopy(editor.cursor)
947 end
948 move.start_of_line(editor)
949 elseif chord == 'S-end' and cursor_on_screen then
950 if editor.selection.line == nil then
951 editor.selection = t.deepcopy(editor.cursor)
952 end
953 move.end_of_line(editor)
954 elseif chord == 'up' and cursor_on_screen then
955 move.up_arrow(editor)
956 editor.selection = {}
957 elseif chord == 'down' and cursor_on_screen then
958 move.down_arrow(editor)
959 editor.selection = {}
960 elseif chord == 'S-up' and cursor_on_screen then
961 if editor.selection.line == nil then
962 editor.selection = t.deepcopy(editor.cursor)
963 end
964 move.up_arrow(editor)
965 elseif chord == 'S-down' and cursor_on_screen then
966 if editor.selection.line == nil then
967 editor.selection = t.deepcopy(editor.cursor)
968 end
969 move.down_arrow(editor)
970 elseif chord == 'pageup' and cursor_on_screen then
971 move.pageup(editor)
972 editor.selection = {}
973 elseif chord == 'pagedown' and cursor_on_screen then
974 move.pagedown(editor)
975 editor.selection = {}
976 elseif chord == 'S-pageup' and cursor_on_screen then
977 if editor.selection.line == nil then
978 editor.selection = t.deepcopy(editor.cursor)
979 end
980 move.pageup(editor)
981 elseif chord == 'S-pagedown' and cursor_on_screen then
982 if editor.selection.line == nil then
983 editor.selection = t.deepcopy(editor.cursor)
984 end
985 move.pagedown(editor)
986 end
987end
988
989function I.insert_text(editor, s)
990 for _,code in utf8.codes(s) do
991 local c = utf8.char(code)
992 if c == '\n' then
993 I.insert_return_at_cursor(editor)
994 else
995 I.insert_char_at_cursor(editor, c)
996 end
997 end
998 move.maybe_snap_cursor_to_bottom_of_screen(editor)
999end
1000
1001function edit.clear(editor)
1002 editor.lines = {{data=''}}
1003 editor.cursor = {line=1, pos=1}
1004 editor.screen_top = {line=1, pos=1}
1005 editor.selection = {}
1006 colorize.all(editor)
1007end
1008
1009function edit.key_release(editor, key, scancode)
1010 -- ignore events for some time after window in focus (mostly alt-tab)
1011 if Current_time < Last_focus_time + 0.01 then
1012 return
1013 end
1014 --
1015 Cursor_time = 0 -- ensure cursor is visible immediately after it moves
1016end
1017
1018function edit.update_font_settings(editor, font_height)
1019 editor.font_height = font_height
1020 editor.font = love.graphics.newFont(editor.font_height)
1021 editor.line_height = math.floor(font_height*1.3)
1022end
1023
1024function I.draw_text_cursor(editor, x, y, dy, cursor_color)
1025 -- blink every 0.5s
1026 if math.floor(Cursor_time*2)%2 == 0 then
1027 love.graphics.setColor(cursor_color or Cursor_color)
1028 love.graphics.rectangle('fill', x,y, 3,dy)
1029 end
1030end
1031
1032function I.insert_char_at_cursor(editor, t)
1033 local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos)
1034 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1)..t..string.sub(editor.lines[editor.cursor.line].data, byte_offset)
1035 editor.cursor.pos = editor.cursor.pos+1
1036end
1037
1038function I.insert_return_at_cursor(editor)
1039 local byte_offset = my_utf8.offset(editor.lines[editor.cursor.line].data, editor.cursor.pos)
1040 table.insert(editor.lines, editor.cursor.line+1, {data=string.sub(editor.lines[editor.cursor.line].data, byte_offset)})
1041 editor.lines[editor.cursor.line].data = string.sub(editor.lines[editor.cursor.line].data, 1, byte_offset-1)
1042 editor.cursor = {line=editor.cursor.line+1, pos=1}
1043end
1044
1045-- mappings only to non-printable keys; leave out mappings that textinput will handle
1046local Numlock_off = {
1047 kp0='insert',
1048 kp1='end',
1049 kp2='down',
1050 kp3='pagedown',
1051 kp4='left',
1052 -- numpad 5 translates to nothing
1053 kp6='right',
1054 kp7='home',
1055 kp8='up',
1056 kp9='pageup',
1057 ['kp.']='delete',
1058 -- LÖVE handles keypad operators in textinput
1059 -- what's with the `kp=` and `kp,` keys? None of my keyboards have one.
1060 -- Hopefully LÖVE handles them as well in textinput.
1061 kpenter='enter',
1062 kpdel='delete',
1063}
1064local Numlock_on = {
1065 kpenter='enter',
1066 kpdel='delete',
1067}
1068function I.translate_numlock(key)
1069 if love.keyboard.isModifierActive('numlock') then
1070 return Numlock_on[key] or key
1071 else
1072 return Numlock_off[key] or key
1073 end
1074 return key
1075end
1076
1077function I.draw_shape(shape, left, top, fg)
1078 love.graphics.setColor(shape.fg or fg)
1079 if shape.type == 'rect' then
1080 love.graphics.rectangle(shape.mode, left+shape.x, top+shape.y, shape.dx, shape.dy)
1081 elseif shape.type == 'line' then
1082 love.graphics.line(left+shape.x1, top+shape.y1, left+shape.x2, top+shape.y2)
1083 elseif shape.type == 'circle' then
1084 love.graphics.circle(shape.mode, left+shape.x, top+shape.y, shape.r)
1085 end
1086end
1087
1088---- helpers for file operations
1089
1090function edit.save_to_disk(editor)
1091 if editor.filename == nil then return end
1092 local lines = {}
1093 for _, line in ipairs(editor.lines) do
1094 table.insert(lines, line.data)
1095 end
1096 local contents = table.concat(lines, '\n')
1097 local success, message = love.filesystem.write(editor.filename, contents)
1098 if not success then
1099 error('failed to write to "'..editor.filename..'": '..message)
1100 end
1101 editor.modified = nil
1102end
1103
1104---- helpers for the search bar (C-f)
1105
1106function I.draw_search_bar(editor)
1107 local screen_width, screen_height = love.graphics.getDimensions()
1108 local h = editor.line_height+2
1109 local y = screen_height-h
1110 love.graphics.setColor(0.9,0.9,0.9)
1111 love.graphics.rectangle('fill', 0, y-10, screen_width-1, h+8)
1112 love.graphics.setColor(0.6,0.6,0.6)
1113 love.graphics.line(0, y-10, screen_width-1, y-10)
1114 love.graphics.setColor(1,1,1)
1115 love.graphics.rectangle('fill', 20, y-6, screen_width-40, h+2, 2,2)
1116 love.graphics.setColor(0.6,0.6,0.6)
1117 love.graphics.rectangle('line', 20, y-6, screen_width-40, h+2, 2,2)
1118 love.graphics.setColor(Foreground_color)
1119 love.graphics.print(editor.search_term, 25,y-5)
1120 I.draw_text_cursor(editor, 25+editor.font:getWidth(editor.search_term),y-5, editor.line_height)
1121end
1122
1123function I.search_next(editor)
1124 if #editor.search_term == 0 then return end
1125 local offset
1126 -- search current line from cursor
1127 local curr_pos = editor.cursor.pos
1128 local curr_line = editor.lines[editor.cursor.line].data
1129 local curr_offset = my_utf8.offset(curr_line, curr_pos)
1130 offset = I.find(curr_line, editor.search_term, curr_offset, --[[literal]] true)
1131 if offset then
1132 editor.cursor.pos = utf8.len(curr_line, 1, offset)
1133 end
1134 if offset == nil then
1135 -- search lines below cursor
1136 for i=editor.cursor.line+1,#editor.lines do
1137 local curr_line = editor.lines[i].data
1138 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true)
1139 if offset then
1140 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)}
1141 break
1142 end
1143 end
1144 end
1145 if offset == nil then
1146 -- wrap around
1147 for i=1,editor.cursor.line-1 do
1148 local curr_line = editor.lines[i].data
1149 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true)
1150 if offset then
1151 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)}
1152 break
1153 end
1154 end
1155 end
1156 if offset == nil then
1157 -- search current line until cursor
1158 local curr_line = editor.lines[editor.cursor.line].data
1159 offset = I.find(curr_line, editor.search_term, --[[from start]] nil, --[[literal]] true)
1160 local pos = utf8.len(curr_line, 1, offset)
1161 if pos and pos < editor.cursor.pos then
1162 editor.cursor.pos = pos
1163 end
1164 end
1165 if offset then
1166 move.maybe_snap_cursor_to_bottom_of_screen(editor)
1167 else
1168 -- roll back
1169 editor.cursor = t.deepcopy(editor.search_backup.cursor)
1170 editor.screen_top = t.deepcopy(editor.search_backup.screen_top)
1171 end
1172end
1173
1174function I.search_previous(editor)
1175 if #editor.search_term == 0 then return end
1176 local offset
1177 -- search current line before cursor
1178 local curr_pos = editor.cursor.pos
1179 local curr_line = editor.lines[editor.cursor.line].data
1180 local curr_offset = my_utf8.offset(curr_line, curr_pos)
1181 offset = I.rfind(curr_line, editor.search_term, curr_offset-1, --[[literal]] true)
1182 if offset then
1183 editor.cursor.pos = utf8.len(curr_line, 1, offset)
1184 end
1185 if offset == nil then
1186 -- search lines above cursor
1187 for i=editor.cursor.line-1,1,-1 do
1188 local curr_line = editor.lines[i].data
1189 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true)
1190 if offset then
1191 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)}
1192 break
1193 end
1194 end
1195 end
1196 if offset == nil then
1197 -- wrap around
1198 for i=#editor.lines,editor.cursor.line+1,-1 do
1199 local curr_line = editor.lines[i].data
1200 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true)
1201 if offset then
1202 editor.cursor = {line=i, pos=utf8.len(curr_line, 1, offset)}
1203 break
1204 end
1205 end
1206 end
1207 if offset == nil then
1208 -- search current line after cursor
1209 local curr_line = editor.lines[editor.cursor.line].data
1210 offset = I.rfind(curr_line, editor.search_term, --[[from end]] nil, --[[literal]] true)
1211 local pos = utf8.len(curr_line, 1, offset)
1212 if pos and pos > editor.cursor.pos then
1213 editor.cursor.pos = pos
1214 end
1215 end
1216 if offset then
1217 move.maybe_snap_cursor_to_top_of_screen(editor)
1218 else
1219 -- roll back
1220 editor.cursor = t.deepcopy(editor.search_backup.cursor)
1221 editor.screen_top = t.deepcopy(editor.search_backup.screen_top)
1222 end
1223end
1224
1225-- return true if cursor marks start of search term and line_index,pos is in that region
1226function I.in_search(editor, line_index, pos)
1227 if editor.search_term == nil then return false end
1228 if #editor.search_term == 0 then return false end
1229 if line_index ~= editor.cursor.line then return false end
1230 return I.find_at(editor.lines[line_index].data, editor.search_term, editor.cursor.pos)
1231 and editor.cursor.pos <= pos and pos <= editor.cursor.pos+utf8.len(editor.search_term)-1
1232end
1233
1234function I.find_at(data, pat, pos)
1235 local offset = utf8.offset(data, pos)
1236 return data:sub(offset, offset+#pat-1) == pat
1237end
1238
1239function I.find(s, pat, i, plain)
1240 if s == nil then return end
1241 return s:find(pat, i, plain)
1242end
1243
1244-- TODO: avoid the expensive reverse() operations
1245-- Particularly if we only care about literal matches, we don't need all of string.find
1246function I.rfind(s, pat, i, plain)
1247 if s == nil then return end
1248 if #pat == 0 then return #s end
1249 local rs = s:reverse()
1250 local rpat = pat:reverse()
1251 if i == nil then i = #s end
1252 local ri = #s - i + 1
1253 local rendpos = rs:find(rpat, ri, plain)
1254 if rendpos == nil then return nil end
1255 local endpos = #s - rendpos + 1
1256 assert (endpos >= #pat, ('rfind: endpos %d should be >= #pat %d at this point'):format(endpos, #pat))
1257 return endpos-#pat+1
1258end
1259
1260function test_rfind()
1261 check_eq(I.rfind('abc', ''), 3, 'empty pattern')
1262 check_eq(I.rfind('abc', 'c'), 3, 'final char')
1263 check_eq(I.rfind('acbc', 'c', 3), 2, 'previous char')
1264 check_nil(I.rfind('abc', 'd'), 'missing char')
1265 check_nil(I.rfind('abc', 'c', 2), 'no more char')
1266end
1267
1268---- helpers for selecting portions of text
1269
1270function I.cut_selection(editor)
1271 if editor.selection.line == nil then return end
1272 local result = I.selection(editor)
1273 I.delete_selection_and_record_undo_event(editor)
1274 return result
1275end
1276
1277function I.delete_selection_and_record_undo_event(editor)
1278 if editor.selection.line == nil then return end
1279 local minl,maxl = t.minmax(editor.selection.line, editor.cursor.line)
1280 local before = I.snapshot(editor, minl, maxl)
1281 I.delete_selection_without_undo(editor)
1282 I.record_undo_event(editor, {before=before, after=I.snapshot(editor, editor.cursor.line)})
1283end
1284
1285function I.delete_selection_without_undo(editor)
1286 if editor.selection.line == nil then return end
1287 -- min,max = sorted(editor.selection,editor.cursor)
1288 local minl,minp = editor.selection.line,editor.selection.pos
1289 local maxl,maxp = editor.cursor.line,editor.cursor.pos
1290 if minl > maxl then
1291 minl,maxl = maxl,minl
1292 minp,maxp = maxp,minp
1293 elseif minl == maxl then
1294 if minp > maxp then
1295 minp,maxp = maxp,minp
1296 end
1297 end
1298 -- update editor.cursor and editor.selection
1299 editor.cursor.line = minl
1300 editor.cursor.pos = minp
1301 if Loc.lt(editor.cursor, editor.screen_top) then
1302 editor.screen_top = move.internal.loc_hor(editor, editor.cursor, editor.left)
1303 end
1304 editor.selection = {}
1305 -- delete everything between min (inclusive) and max (exclusive)
1306 local min_offset = my_utf8.offset(editor.lines[minl].data, minp)
1307 local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp)
1308 if minl == maxl then
1309 editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..editor.lines[minl].data:sub(max_offset)
1310 return
1311 end
1312 assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl))
1313 local rhs = editor.lines[maxl].data:sub(max_offset)
1314 for i=maxl,minl+1,-1 do
1315 table.remove(editor.lines, i)
1316 end
1317 editor.lines[minl].data = editor.lines[minl].data:sub(1, min_offset-1)..rhs
1318end
1319
1320function I.selection(editor)
1321 if editor.selection.line == nil then return end
1322 -- min,max = sorted(editor.selection,editor.cursor)
1323 local minl,minp = editor.selection.line,editor.selection.pos
1324 local maxl,maxp = editor.cursor.line,editor.cursor.pos
1325 if minl > maxl then
1326 minl,maxl = maxl,minl
1327 minp,maxp = maxp,minp
1328 elseif minl == maxl then
1329 if minp > maxp then
1330 minp,maxp = maxp,minp
1331 end
1332 end
1333 local min_offset = my_utf8.offset(editor.lines[minl].data, minp)
1334 local max_offset = my_utf8.offset(editor.lines[maxl].data, maxp)
1335 if minl == maxl then
1336 return editor.lines[minl].data:sub(min_offset, max_offset-1)
1337 end
1338 assert(minl < maxl, ('minl %d not < maxl %d'):format(minl, maxl))
1339 local result = {editor.lines[minl].data:sub(min_offset)}
1340 for i=minl+1,maxl-1 do
1341 table.insert(result, editor.lines[i].data)
1342 end
1343 table.insert(result, editor.lines[maxl].data:sub(1, max_offset-1))
1344 return table.concat(result, '\n')
1345end
1346
1347function I.in_selection(editor, line_index, pos, cursor)
1348 if editor.selection.line == nil then return false end
1349 local curr = {line=line_index, pos=pos}
1350 if Loc.eq(cursor, editor.selection) then
1351 return false
1352 elseif Loc.lt(cursor, editor.selection) then
1353 return Loc.le(cursor, curr) and Loc.lt(curr, editor.selection)
1354 elseif Loc.lt(editor.selection, cursor) then
1355 return Loc.le(editor.selection, curr) and Loc.lt(curr, cursor)
1356 end
1357end
1358
1359---- undo/redo
1360
1361-- undo/redo by managing the sequence of events in the current session
1362-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu
1363
1364-- makes a copy of lines on every single keystroke; will be inefficient with really long lines.
1365-- TODO: highlight stuff inserted by any undo/redo operation
1366-- TODO: coalesce multiple similar operations
1367
1368function I.record_undo_event(editor, data)
1369 editor.history[editor.next_history] = data
1370 editor.next_history = editor.next_history+1
1371 for i=editor.next_history,#editor.history do
1372 editor.history[i] = nil
1373 end
1374end
1375
1376function I.undo_event(editor)
1377 if editor.next_history > 1 then
1378--? print('moving to history', editor.next_history-1)
1379 editor.next_history = editor.next_history-1
1380 local result = editor.history[editor.next_history]
1381 return result
1382 end
1383end
1384
1385function I.redo_event(editor)
1386 if editor.next_history <= #editor.history then
1387--? print('restoring history', editor.next_history+1)
1388 local result = editor.history[editor.next_history]
1389 editor.next_history = editor.next_history+1
1390 return result
1391 end
1392end
1393
1394-- Copy all relevant global editor.
1395-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories.
1396function I.snapshot(editor, s,e)
1397 -- Snapshot everything by default, but subset if requested.
1398 assert(s, 'failed to snapshot operation for undo history')
1399 if e == nil then
1400 e = s
1401 end
1402 assert(#editor.lines > 0, 'failed to snapshot operation for undo history')
1403 if s < 1 then s = 1 end
1404 if s > #editor.lines then s = #editor.lines end
1405 if e < 1 then e = 1 end
1406 if e > #editor.lines then e = #editor.lines end
1407 -- compare with edit.new
1408 local event = {
1409 screen_top=t.deepcopy(editor.screen_top),
1410 selection=t.deepcopy(editor.selection),
1411 cursor=t.deepcopy(editor.cursor),
1412 lines={},
1413 start_line=s,
1414 end_line=e,
1415 -- no filename; undo history is cleared when filename changes
1416 }
1417 for i=s,e do
1418 table.insert(event.lines, t.deepcopy(editor.lines[i]))
1419 end
1420 return event
1421end
1422
1423function I.patch(lines, from, to)
1424--? if #from.lines == 1 and #to.lines == 1 then
1425--? assert(from.start_line == from.end_line)
1426--? assert(to.start_line == to.end_line)
1427--? assert(from.start_line == to.start_line)
1428--? lines[from.start_line] = to.lines[1]
1429--? return
1430--? end
1431 assert(from.start_line == to.start_line, 'failed to patch undo operation')
1432 for i=from.end_line,from.start_line,-1 do
1433 table.remove(lines, i)
1434 end
1435 assert(#to.lines == to.end_line-to.start_line+1, 'failed to patch undo operation')
1436 for i=1,#to.lines do
1437 table.insert(lines, to.start_line+i-1, to.lines[i])
1438 end
1439end
1440
1441return edit