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:
parent
a9a133e6fb
commit
670886240f
|
@ -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:
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
5
main.lua
5
main.lua
|
@ -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
125
text.lua
|
@ -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()
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue