Undo Class?

Whilst slowly making progress on my project, I realised I needed to give users the option to undo changes they will make in my app (Flip Pad Animator). It started me thinking on how to best approach coding this, and two thoughts sprang to mind; storing copies of a class instance’s variables prior to each action being carried out (so I can reset them), or an undo class that could handle undo actions for any global class.

In my app, a user will be able to add drawing objects to the screen, then move, resize and rotate them, make them visible/invisible, delete them, etc. So, many actions with many variables, which could mean many parameters and actions to carry out which might not be as simple as reverting variable values.

After looking at other code examples, discovering some info on meta tables and meta methods (haven’t really grasped them yet), I wondered if they could be used to enable a single function to reverse a change made to any global class instance? For example, if I rotated a line object by X degrees by calling a Line:Rotate function and passing it the class instance and X, could I store that combination somewhere (instance, function, params), and later retrieve info about the Line:Rotate function so I could call it again and pass the class instance and -X to it? If this was a written as class, each undo class instance could be for a different global class instance and action, and so could transfer to any project.

Not sure if I am barking up the wrong tree or if there is another standard way of doing this. Would appreciate any thoughts or advice.

for what it is worth: i’ve made couple of undo in programs (like mesh drawer for instance). Here is what i did. I have to be able to save my result, so a save() function already exist. For undo, i make some save() automatically when i change the menu. Not too many (10) not too often. Then i can undo just by loading the previous version. Good enough.
Your idea of ‘revert everything’ is a nice principle, but too complex to put in general practice (too much work), i think, imho.

I think it might be possible, and really nice, but it might require some black magic

Where’s @toffer when you need him :))

@JakAttack - :slight_smile: some white magic would be better in this case, and I am clearly not the good one!. @time_trial, I’m not sure to well understand your needs, but you should take a look at the memento pattern, in my opinion, it’s better to explicitly control when to store ‘undo data’ and delegate it to an external component. Sure you could hook the __index metamethod of classes or use a global method that grab methods calls and arguments and then strore it to do the undo stuff, but that will be very expensive task. Also, for the fun of the exercise here is a don’t use it example (I’m the one who give code you should not use, very usefull :P)

-- very expensive memento
-- decorate methods calls with closures, pack,unpack arguments...
-- version: do not use it for real
local cursor,hold = 1
memento = {}
memento.undo = function()
    cursor = cursor - 1
    local mem = memento[cursor]
    if mem then
        hold = true
        mem[1].undo(unpack(mem))
        hold = false
    else
        print("Nothing to undo !")
        cursor = cursor + 1
    end
end
-- decorate each method call for a class
memento.wrap = function(klass)
    klass.__index = function(t,k)
        local v = rawget(klass,k)
        -- each time a method is requested on the class instance
        -- a closure is returned in order to catch the method's name
        -- and it's arguments
        if not hold then
            if k ~= "undo" and type(v) == "function" then
                return function(t,...)
                    memento[cursor] = {t,k,...}
                    cursor = cursor + 1
                    v(t,...)
                end
            end
        end
        return v
    end
end

--[[
(1) And another very expensive overwrite,
Each class created wraped by the memento
local oclass = class
function class(base)
    local cl = oclass(base)
    memento.wrap(cl)
    return cl
end
]]

-- A test class
Line = class()
function Line:rotate(angle)
    print("rotate line by", angle)
end
-- implements undo function
function Line:undo(fkey,...)
    -- here, fkey is our method's name
    if fkey == "rotate" then
        print("> undo rotate")
        self:rotate(-arg[1])
    end
end

function setup()
    memento.wrap(Line) -- < no need if class is overwrited (1)
    l = Line()
    parameter.action("undo",memento.undo)
    parameter.integer("rotate",1,5,1,function(val)
        l:rotate(val)
    end)
end

function draw() end

Thanks for the feedback.

@Jmv38, I will check your mesh program to see how you did undo. I will need to revert vertices to their previous position so should be able to learn from your code.

‘in my opinion, it’s better to explicitly control when to store ‘undo data’ and delegate it to an external component.’

@toffer, this is what I thought I was proposing! Thanks for the example code and the advice, if retrieving the class function is going to be really expensive, then I will give up the idea. Amazing how you wrote the example so quickly!

@time_trial - retrieving the class method is not the most expensive rather than the arguments passed in… Also here is another approach if this can help you (and that gave me a little break from my work :slight_smile: ) :

Memento = class()
-- @param target object
-- @varg list of properties to store
function Memento:init(target,...)
	self.target = target
	self.properties = { ... }
end
-- Store state
function Memento:pushstate()
	local target,mem = self.target,{}
	-- here we could handle properties type's (userdata,table...)
	for _,k in ipairs(self.properties) do
		mem[k] = target[k]
	end
	self[#self + 1] = mem
	return self
end
-- Revert to previous state
function Memento:popstate()
	local mem = table.remove(self)
	if mem then
		local target,properties = self.target,self.properties
		-- here we could handle properties type's (userdata,table...)
		for k,v in pairs(mem) do
			target[k] = v
		end
	end
	return self
end

MetaMemento = class()
-- @params list of Memento
function MetaMemento:init(...)
	for _,memento in ipairs({...}) do
		self[#self + 1] = memento
	end
end
-- Store state
function MetaMemento:pushstate()
	for _,memento in ipairs(self) do
		memento:pushstate()
	end
	return self
end
-- Revert to previous state
function MetaMemento:popstate()
	for _,memento in ipairs(self) do
		memento:popstate()
	end
	return self
end

-- test class
Line = class()
function Line:init(name)
	-- class itself could hold is memento(s)
	-- self.memento = Memento(self,"x","y")
	-- and manage his push/pop states
	self.name = name
	self.x = 0
	self.y = 0
end
function Line:move(x,y)
	self.x = x
	self.y = y
end
Line.__tostring = function(self)
	return string.format("[%s x:%d,y:%d]",self.name,self.x,self.y)
end

-- ie : per instance memento
print('-- per instance memento')
lin1 = Line("line1")
lin2 = Line("line2")
mem1 = Memento(lin1,"x")		-- will store x states values of line1
mem2 = Memento(lin2,"x","y")	-- will store x and y states values of line2
-- push initials states
mem1:pushstate()
mem2:pushstate()					
print("initial state",lin1,lin2)
-- update lines instances values
lin1:move(100,100)
lin2:move(200,200)
print("set values",lin1,lin2)
-- restore to previous states
mem1:popstate()
mem2:popstate()
print("undo",lin1,lin2)

-- ie : memento wrapper
print('-- memento wrapper')
metamem = MetaMemento(mem1,mem2)
metamem:pushstate()
print("initial state",lin1,lin2)
lin1:move(15,10)
lin2:move(-50,80)
print("set values",lin1,lin2)
metamem:popstate()
print("undo",lin1,lin2)

Ok, here you have my silly approach:

function DrawAction()
    table.insert(UndoList,1,TableWithDrawingData)
    if #UndoList > maxUndo then
        table.remove(UndoList, maxUndo+1)
    end
    RedoList = {}
end

function UndoAction()
    if #UndoList > 1 then
        table.insert(RedoList,1,UndoList[1])
        if #RedoList > maxUndo then
            table.remove(RedoList, maxUndo+1)
        end
        table.remove(UndoList,1)
        LoadFromTable(UndoList[1])
    end
end

function RedoAction()
    if #RedoList > 0 then
        table.insert(UndoList,1,RedoList[1])
        if #UndoList > maxUndo then
            table.remove(UndoList, maxUndo+1)
        end
        table.remove(RedoList,1)
        LoadFromTable(UndoList[1])
    end
end

Thanks again, @toffer. I might never understand how your code works, but I will try and I will surely learn something from it! Metamemento sounds like a place in Italy :wink:

@MoNus, I can understand your code and I will probably use it, so thanks to you too. At least the idea has spawned some discussion and some good code!

https://www.youtube.com/watch?v=tSeYShR-OG0