after much struggle, a brute-force undo

Incredibly inefficient, but I don't yet know how to efficiently encode
undo mutations that can span multiple lines.

There seems to be one bug related to creating new drawings; they're not
spawning events and undoing past drawing creation has some weird
artifacts. Redo seems to consistently work, though.
This commit is contained in:
Kartik K. Agaram 2022-06-02 15:45:25 -07:00
parent a9a133e6fb
commit 670886240f
5 changed files with 214 additions and 0 deletions

View File

@ -6,6 +6,10 @@ 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.
* The text cursor will always stay on the screen. This can have some strange
implications:

View File

@ -206,6 +206,7 @@ function Drawing.in_drawing(drawing, x,y)
end
function Drawing.mouse_pressed(drawing, x,y, button)
Drawing.before = snapshot_everything()
if Current_drawing_mode == 'freehand' then
drawing.pending = {mode=Current_drawing_mode, points={{x=Drawing.coord(x-16), y=Drawing.coord(y-drawing.y)}}}
elseif Current_drawing_mode == 'line' or Current_drawing_mode == 'manhattan' then
@ -347,6 +348,7 @@ function Drawing.mouse_released(x,y, button)
end
end
save_to_disk(Lines, Filename)
record_undo_event({before=Drawing.before, after=snapshot_everything()})
end
function Drawing.keychord_pressed(chord)

View File

@ -65,6 +65,10 @@ Zoom = 1.5
Filename = love.filesystem.getUserDirectory()..'/lines.txt'
-- undo
History = {}
Next_history = 1
end -- App.initialize_globals
function App.initialize(arg)
@ -102,6 +106,7 @@ function App.initialize(arg)
end -- App.initialize
function App.filedropped(file)
App.initialize_globals() -- in particular, forget all undo history
Filename = file:getFilename()
file:open('r')
Lines = load_from_file(file)

125
text.lua
View File

@ -3,6 +3,8 @@ Text = {}
local utf8 = require 'utf8'
require 'undo'
-- return values:
-- y coordinate drawn until in px
-- position of start of final screen line drawn
@ -1091,6 +1093,83 @@ function test_backspace_to_start_of_line()
check_nil(Selection1.line, "F - test_backspace_to_start_of_line/selection")
end
function test_undo_insert_text()
io.write('\ntest_undo_insert_text')
App.screen.init{width=120, height=60}
Lines = load_array{'abc', 'def', 'xyz'}
Line_width = App.screen.width
Cursor1 = {line=2, pos=4}
Screen_top1 = {line=1, pos=1}
Screen_bottom1 = {}
Zoom = 1
-- insert a character
App.run_after_textinput('g')
check_eq(Cursor1.line, 2, 'F - test_undo_insert_text/baseline/cursor:line')
check_eq(Cursor1.pos, 5, 'F - test_undo_insert_text/baseline/cursor:pos')
check_nil(Selection1.line, 'F - test_undo_insert_text/baseline/selection:line')
check_nil(Selection1.pos, 'F - test_undo_insert_text/baseline/selection:pos')
local screen_top_margin = 15 -- pixels
local line_height = 15 -- pixels
local y = screen_top_margin
App.screen.check(y, 'abc', 'F - test_undo_insert_text/baseline/screen:1')
y = y + line_height
App.screen.check(y, 'defg', 'F - test_undo_insert_text/baseline/screen:2')
y = y + line_height
App.screen.check(y, 'xyz', 'F - test_undo_insert_text/baseline/screen:3')
-- undo
App.run_after_keychord('M-z')
check_eq(Cursor1.line, 2, 'F - test_undo_insert_text/cursor:line')
check_eq(Cursor1.pos, 4, 'F - test_undo_insert_text/cursor:pos')
check_nil(Selection1.line, 'F - test_undo_insert_text/selection:line')
check_nil(Selection1.pos, 'F - test_undo_insert_text/selection:pos')
y = screen_top_margin
App.screen.check(y, 'abc', 'F - test_undo_insert_text/screen:1')
y = y + line_height
App.screen.check(y, 'def', 'F - test_undo_insert_text/screen:2')
y = y + line_height
App.screen.check(y, 'xyz', 'F - test_undo_insert_text/screen:3')
end
function test_undo_delete_text()
io.write('\ntest_undo_delete_text')
App.screen.init{width=120, height=60}
Lines = load_array{'abc', 'defg', 'xyz'}
Line_width = App.screen.width
Cursor1 = {line=2, pos=5}
Screen_top1 = {line=1, pos=1}
Screen_bottom1 = {}
Zoom = 1
-- delete a character
App.run_after_keychord('backspace')
check_eq(Cursor1.line, 2, 'F - test_undo_delete_text/baseline/cursor:line')
check_eq(Cursor1.pos, 4, 'F - test_undo_delete_text/baseline/cursor:pos')
check_nil(Selection1.line, 'F - test_undo_delete_text/baseline/selection:line')
check_nil(Selection1.pos, 'F - test_undo_delete_text/baseline/selection:pos')
local screen_top_margin = 15 -- pixels
local line_height = 15 -- pixels
local y = screen_top_margin
App.screen.check(y, 'abc', 'F - test_undo_delete_text/baseline/screen:1')
y = y + line_height
App.screen.check(y, 'def', 'F - test_undo_delete_text/baseline/screen:2')
y = y + line_height
App.screen.check(y, 'xyz', 'F - test_undo_delete_text/baseline/screen:3')
-- undo
--? -- after undo, the backspaced key is selected
App.run_after_keychord('M-z')
check_eq(Cursor1.line, 2, 'F - test_undo_delete_text/cursor:line')
check_eq(Cursor1.pos, 5, 'F - test_undo_delete_text/cursor:pos')
check_nil(Selection1.line, 'F - test_undo_delete_text/selection:line')
check_nil(Selection1.pos, 'F - test_undo_delete_text/selection:pos')
--? check_eq(Selection1.line, 2, 'F - test_undo_delete_text/selection:line')
--? check_eq(Selection1.pos, 4, 'F - test_undo_delete_text/selection:pos')
y = screen_top_margin
App.screen.check(y, 'abc', 'F - test_undo_delete_text/screen:1')
y = y + line_height
App.screen.check(y, 'defg', 'F - test_undo_delete_text/screen:2')
y = y + line_height
App.screen.check(y, 'xyz', 'F - test_undo_delete_text/screen:3')
end
function Text.compute_fragments(line, line_width)
--? print('compute_fragments', line_width)
line.fragments = {}
@ -1142,6 +1221,8 @@ 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_everything()
local byte_offset
if Cursor1.pos > 1 then
byte_offset = utf8.offset(Lines[Cursor1.line].data, Cursor1.pos)
@ -1152,6 +1233,8 @@ 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_everything()})
end
-- Don't handle any keys here that would trigger love.textinput above.
@ -1159,6 +1242,7 @@ function Text.keychord_pressed(chord)
--? print(chord)
--== shortcuts that mutate text
if chord == 'return' then
local before = snapshot_everything()
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
@ -1171,12 +1255,18 @@ 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_everything()})
elseif chord == 'tab' then
local before = snapshot_everything()
Text.insert_at_cursor('\t')
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot_everything()})
elseif chord == 'backspace' then
local before = snapshot_everything()
if Selection1.line then
Text.delete_selection()
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot_everything()})
return
end
if Cursor1.pos > 1 then
@ -1210,9 +1300,13 @@ function Text.keychord_pressed(chord)
end
assert(Text.le1(Screen_top1, Cursor1))
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot_everything()})
elseif chord == 'delete' then
local before = snapshot_everything()
if Selection1.line then
Text.delete_selection()
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot_everything()})
return
end
if Cursor1.pos <= utf8.len(Lines[Cursor1.line].data) then
@ -1238,6 +1332,37 @@ function Text.keychord_pressed(chord)
end
end
save_to_disk(Lines, Filename)
record_undo_event({before=before, after=snapshot_everything()})
-- 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
local event = undo_event()
if event then
local src = event.before
Screen_top1 = deepcopy(src.screen_top)
Cursor1 = deepcopy(src.cursor)
Selection1 = deepcopy(src.selection)
if src.lines then
Lines = deepcopy(src.lines)
end
end
elseif chord == 'M-y' then
local event = redo_event()
if event then
local src = event.after
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
end
-- paste
elseif chord == 'M-c' then
local s = Text.selection()

78
undo.lua Normal file
View File

@ -0,0 +1,78 @@
-- undo/redo by managing the sequence of events in the current session
-- based on https://github.com/akkartik/mu1/blob/master/edit/012-editor-undo.mu
-- Incredibly inefficient; we make a copy of lines on every single keystroke.
-- The hope here is that we're either editing small files or just reading large files.
-- TODO: highlight stuff inserted by any undo/redo operation
-- TODO: coalesce multiple similar operations
function record_undo_event(data)
History[Next_history] = data
Next_history = Next_history+1
for i=Next_history,#History do
History[i] = nil
end
end
function undo_event()
if Next_history > 1 then
--? print('moving to history', Next_history-1)
Next_history = Next_history-1
local result = History[Next_history]
return result
end
end
function redo_event()
if Next_history <= #History then
--? print('restoring history', Next_history+1)
local result = History[Next_history]
Next_history = Next_history+1
return result
end
end
-- Make copies of objects; the rest of the app may mutate them in place, but undo requires immutable histories.
function snapshot_everything()
-- compare with App.initialize_globals
local event = {
screen_top=deepcopy(Screen_top1),
selection=deepcopy(Selection1),
cursor=deepcopy(Cursor1),
current_drawing_mode=Drawing_mode,
previous_drawing_mode=Previous_drawing_mode,
zoom=Zoom,
lines={},
-- no filename; undo history is cleared when filename changes
}
-- deep copy lines without cached stuff like text fragments
for _,line in ipairs(Lines) do
if line.mode == 'text' then
table.insert(event.lines, {mode='text', data=line.data})
elseif line.mode == 'drawing' then
local points=deepcopy(line.points)
--? print('copying', line.points, 'with', #line.points, 'points into', points)
local shapes=deepcopy(line.shapes)
--? print('copying', line.shapes, 'with', #line.shapes, 'shapes into', shapes)
table.insert(event.lines, {mode='drawing', y=line.y, h=line.h, points=points, shapes=shapes, pending={}})
--? table.insert(event.lines, {mode='drawing', y=line.y, h=line.h, points=deepcopy(line.points), shapes=deepcopy(line.shapes), pending={}})
else
print(line.mode)
assert(false)
end
end
return event
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
if seen and seen[obj] then return seen[obj] end
local s = seen or {}
local result = setmetatable({}, getmetatable(obj))
s[obj] = result
for k,v in pairs(obj) do
result[deepcopy(k, s)] = deepcopy(v, s)
end
return result
end