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.
This commit is contained in:
Kartik K. Agaram 2022-06-02 17:46:30 -07:00
parent 22817492a3
commit 4f76ea37d7
3 changed files with 73 additions and 61 deletions

View File

@ -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:

View File

@ -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

View File

@ -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