From 4f76ea37d7a24e6562fb8640935750c50de62e69 Mon Sep 17 00:00:00 2001 From: "Kartik K. Agaram" Date: Thu, 2 Jun 2022 17:46:30 -0700 Subject: [PATCH] more efficient undo/redo Now the bottleneck shifts to applying undo/redo in large files. But things should be snappy if you don't use the sluggish feature. --- README.md | 5 ++- text.lua | 92 ++++++++++++++++++++++--------------------------------- undo.lua | 37 ++++++++++++++++++++-- 3 files changed, 73 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 871379d..7eb3a5e 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,8 @@ http://akkartik.name/lines.html * No support yet for Unicode graphemes spanning multiple codepoints. -* Undo is extremely inefficient in space. While this app is extremely unlikely - to lose the current state of a file at any moment, undo history is volatile - and should be considered unstable. +* Undo/redo can be sluggish in large files. If things get sluggish, killing + the process can lose data. * The text cursor will always stay on the screen. This can have some strange implications: diff --git a/text.lua b/text.lua index ee09011..493cbea 100644 --- a/text.lua +++ b/text.lua @@ -132,6 +132,13 @@ function Text.clip_selection(line_index, apos, bpos) end function Text.delete_selection() + local minl,maxl = minmax(Selection1.line, Cursor1.line) + local before = snapshot(minl, maxl) + Text.delete_selection_without_undo() + record_undo_event({before=before, after=snapshot(Cursor1.line)}) +end + +function Text.delete_selection_without_undo() if Selection1.line == nil then return end -- min,max = sorted(Selection1,Cursor1) local minl,minp = Selection1.line,Selection1.pos @@ -1170,34 +1177,6 @@ function test_undo_delete_text() App.screen.check(y, 'xyz', 'F - test_undo_delete_text/screen:3') end -function test_zzz_undo_load_test() - print('\n\nTesting a 10KB file') - -- create a large list of lines - local lines = {} - -- 10k characters, hundred lines, 2k words - for i=1,100 do - local line = '' - for c=1,20 do - line = line..'abcd ' - end - end - Lines = load_array(lines) - -- perform 1000 mutations - print('are the dots printing quickly and without any pauses?') - for i=1,1000 do - if i%50 == 0 then - App.run_after_keychord('return') - else - App.run_after_textinput('a') - end - if i%10 == 0 then - io.write(i) - io.write(' ') - io.flush() - end - end -end - function Text.compute_fragments(line, line_width) --? print('compute_fragments', line_width) line.fragments = {} @@ -1243,14 +1222,15 @@ end function Text.textinput(t) if love.mouse.isDown('1') then return end if App.ctrl_down() or App.alt_down() or App.cmd_down() then return end - local down = love.keyboard.isDown + if Selection1.line then + Text.delete_selection() + end + local before = snapshot(Cursor1.line) Text.insert_at_cursor(t) + record_undo_event({before=before, after=snapshot(Cursor1.line)}) end function Text.insert_at_cursor(t) - if Selection1.line then Text.delete_selection() end - -- Collect what you did in an event that can be undone. - local before = snapshot() local byte_offset if Cursor1.pos > 1 then byte_offset = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos) @@ -1261,8 +1241,6 @@ function Text.insert_at_cursor(t) Lines[Cursor1.line].fragments = nil Lines[Cursor1.line].screen_line_starting_pos = nil Cursor1.pos = Cursor1.pos+1 - -- finalize undo event - record_undo_event({before=before, after=snapshot()}) end -- Don't handle any keys here that would trigger love.textinput above. @@ -1270,7 +1248,8 @@ function Text.keychord_pressed(chord) --? print(chord) --== shortcuts that mutate text if chord == 'return' then - local before = snapshot() + local before_line = Cursor1.line + local before = snapshot(before_line) local byte_offset = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos) table.insert(Lines, Cursor1.line+1, {mode='text', data=string.sub(Lines[Cursor1.line].data, byte_offset)}) local scroll_down = (Cursor_y + math.floor(15*Zoom)) > App.screen.height @@ -1283,20 +1262,24 @@ function Text.keychord_pressed(chord) Screen_top1.line = Cursor1.line Text.scroll_up_while_cursor_on_screen() end - record_undo_event({before=before, after=snapshot()}) + record_undo_event({before=before, after=snapshot(before_line, Cursor1.line)}) elseif chord == 'tab' then - local before = snapshot() + local before = snapshot(Cursor1.line) Text.insert_at_cursor('\t') save_to_disk(Lines, Filename) - record_undo_event({before=before, after=snapshot()}) + record_undo_event({before=before, after=snapshot(Cursor1.line)}) elseif chord == 'backspace' then - local before = snapshot() if Selection1.line then Text.delete_selection() save_to_disk(Lines, Filename) - record_undo_event({before=before, after=snapshot()}) return end + local before + if Cursor1.pos > 1 then + before = snapshot(Cursor1.line) + else + before = snapshot(Cursor1.line-1, Cursor1.line) + end if Cursor1.pos > 1 then local byte_start = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos-1) local byte_end = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos) @@ -1328,15 +1311,19 @@ function Text.keychord_pressed(chord) end assert(Text.le1(Screen_top1, Cursor1)) save_to_disk(Lines, Filename) - record_undo_event({before=before, after=snapshot()}) + record_undo_event({before=before, after=snapshot(Cursor1.line)}) elseif chord == 'delete' then - local before = snapshot() if Selection1.line then Text.delete_selection() save_to_disk(Lines, Filename) - record_undo_event({before=before, after=snapshot()}) return end + local before + if Cursor1.pos <= utf8.len(Lines[Cursor1.line].data) then + before = snapshot(Cursor1.line) + else + before = snapshot(Cursor1.line, Cursor1.line+1) + end if Cursor1.pos <= utf8.len(Lines[Cursor1.line].data) then local byte_start = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos) local byte_end = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos+1) @@ -1360,7 +1347,7 @@ function Text.keychord_pressed(chord) end end save_to_disk(Lines, Filename) - record_undo_event({before=before, after=snapshot()}) + record_undo_event({before=before, after=snapshot(Cursor1.line)}) -- undo/redo really belongs in main.lua, but it's here so I can test the -- text-specific portions of it elseif chord == 'M-z' then @@ -1370,9 +1357,7 @@ function Text.keychord_pressed(chord) Screen_top1 = deepcopy(src.screen_top) Cursor1 = deepcopy(src.cursor) Selection1 = deepcopy(src.selection) - if src.lines then - Lines = deepcopy(src.lines) - end + patch(Lines, event.after, event.before) end elseif chord == 'M-y' then local event = redo_event() @@ -1381,15 +1366,7 @@ function Text.keychord_pressed(chord) Screen_top1 = deepcopy(src.screen_top) Cursor1 = deepcopy(src.cursor) Selection1 = deepcopy(src.selection) - if src.lines then - Lines = deepcopy(src.lines) ---? for _,line in ipairs(Lines) do ---? if line.mode == 'drawing' then ---? print('restoring', line.points, 'with', #line.points, 'points') ---? print('restoring', line.shapes, 'with', #line.shapes, 'shapes') ---? end ---? end - end + patch(Lines, event.before, event.after) end -- paste elseif chord == 'M-c' then @@ -1403,10 +1380,13 @@ function Text.keychord_pressed(chord) love.system.setClipboardText(s) end elseif chord == 'M-v' then + local before_line = Cursor1.line + local before = snapshot(before_line) local s = love.system.getClipboardText() for _,code in utf8.codes(s) do Text.insert_at_cursor(utf8.char(code)) end + record_undo_event({before=before, after=snapshot(before_line, Cursor1.line)}) --== shortcuts that move the cursor elseif chord == 'left' then if Selection1.line then diff --git a/undo.lua b/undo.lua index bc47f05..e196d24 100644 --- a/undo.lua +++ b/undo.lua @@ -32,8 +32,16 @@ function redo_event() end end +-- Copy all relevant global state. -- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories. -function snapshot() +function snapshot(s,e) + -- Snapshot everything by default, but subset if requested. + if s == nil and e == nil then + s = 1 + e = #Lines + elseif e == nil then + e = s + end -- compare with App.initialize_globals local event = { screen_top=deepcopy(Screen_top1), @@ -43,10 +51,13 @@ function snapshot() previous_drawing_mode=Previous_drawing_mode, zoom=Zoom, lines={}, + start_line=s, + end_line=e, -- no filename; undo history is cleared when filename changes } -- deep copy lines without cached stuff like text fragments - for _,line in ipairs(Lines) do + for i=s,e do + local line = Lines[i] if line.mode == 'text' then table.insert(event.lines, {mode='text', data=line.data}) elseif line.mode == 'drawing' then @@ -64,6 +75,24 @@ function snapshot() return event end +function patch(lines, from, to) +--? if #from.lines == 1 and #to.lines == 1 then +--? assert(from.start_line == from.end_line) +--? assert(to.start_line == to.end_line) +--? assert(from.start_line == to.start_line) +--? lines[from.start_line] = to.lines[1] +--? return +--? end + assert(from.start_line == to.start_line) + for i=from.end_line,from.start_line,-1 do + table.remove(lines, i) + end + assert(#to.lines == to.end_line-to.start_line+1) + for i=1,#to.lines do + table.insert(lines, to.start_line+i-1, to.lines[i]) + end +end + -- https://stackoverflow.com/questions/640642/how-do-you-copy-a-lua-table-by-value/26367080#26367080 function deepcopy(obj, seen) if type(obj) ~= 'table' then return obj end @@ -76,3 +105,7 @@ function deepcopy(obj, seen) end return result end + +function minmax(a, b) + return math.min(a,b), math.max(a,b) +end