Implement Undo/Redo
Undo is important for the user to be able to correct mistakes, but it is extremely
difficult to implement. There are several undo/redo architectures. In magic-class
, an
undoable method is defined by a forward function and a reverse function. When a
forward function converts GUI state from A to B, then the reverse function should do the
opposite (B to A).
The undo/redo operations can be executed from the macro instance using ui.macro.undo
and ui.macro.redo
.
Basic Syntax
magicclass.undo.undo_callback
is a decorator that converts a function into a callback
that can be recognized by magic-classes. Returned callback will be properly processed so
that the GUI operations can be recorded in the undo stack.
Note
You don't have to define the redo function. The redo action can be automatically defined using the GUI macro strings.
Standard methods
For standard methods, just return the undo callback.
from magicclass import magicclass
from magicclass.undo import undo_callback
@magicclass
class A:
...
def func(self, ...):
########################
# forward function #
########################
@undo_callback
def undo():
########################
# reverse function #
########################
return undo
Note
The reason why we need to define the reverse function inside the forward function is that the reverse function usually needs the local variables of the forward function to return the GUI state to the original.
An example of undoable setter method is like this:
from magicclass import magicclass
from magicclass.undo import undo_callback
@magicclass
class A:
def __init__(self):
self._x = 0
def set_x(self, x: int):
old_state = x
self._x = x
@undo_callback
def undo():
self._x = old_state
return undo
Thread workers
When you use multi-threading, you'll usually return returned-callbacks, which seems to collide with the undo callback. In this case, you can return an undo callback from the returned-callback.
from magicclass.utils import thread_worker
@magicclass
class A:
@thread_worker
def long_running_function(self, ...):
########################
# forward function #
########################
@undo_callback
def undo():
########################
# reverse function #
########################
@thread_worker.callback
def out():
########################
# returned-callback #
########################
return undo
return out
The Undo Stack
Executed undoable operations are all stored in the "undo stack". Suppose you've defined
two undoable methods f
, g
and a non-undoable method not_undoable
in magic class
A
, the undo stack will change as follow.
# Undo list / redo list
ui = A() # [], []
ui.f(x=0) # [<ui.f(x=0)>], []
ui.g(y=1) # [<ui.f(x=0)>, <ui.g(y=1)>], []
ui.macro.undo() # [<ui.f(x=0)>], [<ui.g(y=1)>]
ui.macro.undo() # [], [<ui.f(x=0)>, <ui.g(y=1)>]
ui.macro.undo() # [], [<ui.f(x=0)>, <ui.g(y=1)>] (excessive undo does nothing)
ui.macro.redo() # [<ui.f(x=0)>], [<ui.g(y=1)>]
ui.macro.redo() # [<ui.f(x=0)>, <ui.g(y=1)>], []
ui.macro.redo() # [<ui.f(x=0)>, <ui.g(y=1)>], [] (excessive redo does nothing)
ui.not_undoable() # [], [] (non-undoable function call clears the undo stack)
Since undo operation is tightly connected to the macro, non-recordable methods will not added to undo stack, nor will they clear the undo stack when get called.
@magicclass
class A:
@do_not_record
def non_recordable(self): ...
def undoable(self):
@undo_callback
def out():
...
return out
# Undo list / redo list
ui = A() # [], []
ui.undoable() # [<ui.undoable()>], []
ui.undoable() # [<ui.undoable()>] * 2, []
ui.non_recordable() # [<ui.undoable()>] * 2, []
ui.undoable() # [<ui.undoable()>] * 3, []
Custom Redo Action
Redo action is defined by the GUI macro string. However, you can also define it by yourself. It is useful when the forward function is a long-running task.
Following GUI can calculate the _very_heavy_task
with the given x
and show the
result in the self.result
widget.
from magicclass import magicclass, vfield
from magicclass.undo import undo_callback
@magicclass
class A:
result = vfield(int)
def func(self, x: int):
old_result = self.result
result = self._very_heavy_task(x)
self.result = result
@undo_callback
def out():
self.result = old_result # undo
return out
Although the undo/redo operations are well-defined, it takes a long time again to redo.
ui = A()
ui.func(1) # long-running task
ui.macro.undo() # very fast
ui.macro.redo() # long-running task again!!
Function decorated by magicclass.undo.undo_callback
has an attribute with_redo
, which allows you to define the redo action similar to the getter/setter definition of
property
.
@magicclass
class A:
result = vfield(int)
def func(self, x: int):
old_result = self.result
result = self._very_heavy_task(x)
self.result = result
@undo_callback
def out():
self.result = old_result # undo
@out.with_redo
def out():
self.result = result # redo
return out
ui = A()
ui.func(1) # long-running task
ui.macro.undo() # very fast
ui.macro.redo() # very fast!!
Best Practice of Undo/Redo
Undo/Redo should be called in GUI in most cases. Many applications map the key sequence
Ctrl+Z
to undo and Ctrl+Y
to redo, or add tool buttons to do the same things.
In magicclass
, you can simply call ui.macro.undo
and ui.macro.redo
in the desired
place. However, there are some points that you have to be careful about.
-
Do not macro-record undo/redo methods themselves.
Recording undo/redo methods will block the undo stack from undo/redo execution.
from magicclass import do_not_record @magicclass class A: def func(self): # do some undoable stuff @do_not_record # use this decorator to avoid recording def undo(self): self.macro.undo() @do_not_record def redo(self): self.macro.redo()
-
Do not rely on the GUI state within the method.
GUI state is the global state. Relying on the global state is very error prone. There's a bug in following code.
from magicclass import magicclass, vfield from magicclass.undo import undo_callback # widget that set "value" to "inner_value" when clicked. @magicclass class A: inner_value = vfield(int, record=False) value = vfield(int, record=False) def apply_value(self): old_value = self.inner_value self.inner_value = self.value @undo_callback def out(): self.inner_value = self.value = old_value return out
The redo step will fail in following steps.
- Manually set
value
to 1. - Click "apply_value" button.
inner_value
is now 1. - Run
ui.macro.undo()
. Bothinner_value
andvalue
are now 0. - Run
ui.macro.redo()
. Sincevalue
is 0,inner_value
is also 0 (redo fails).
The reason is that
value
is a global state, which changes during undo/redo. To fix this, you can provide the value as a parameter to the method. The best way is to use the bind options.from magicclass import magicclass, vfield from magicclass.undo import undo_callback from typing import Annotated @magicclass class A: inner_value = vfield(int, record=False) value = vfield(int, record=False) def apply_value(self, value: Annotated[int, {"bind": value}]): old_value = self.inner_value self.inner_value = self.value = value @undo_callback def out(): self.inner_value = self.value = old_value return out
- Manually set
-
Make sure the recorded macro is executable.
If you don't use custom redo actions, the redo operation fully relies on the macro string. If the macro string is not executable, the redo operation will fail. In following example, redo does not work.
import numpy as np from magicclass import magicclass, set_options, vfield from magicclass.undo import undo_callback def get_array(*_): return np.arange(10) @magicclass class A: array = vfield(str, record=False) @set_options(x={"bind": get_array}) def show_array(self, x): old_str = self.array self.array = str(x) @undo_callback def out(): self.array = old_str return out
macro-kit
does not implement the object-to-string conversion fornumpy.ndarray
by default because the array data can potentially be very large. To avoid this, you can pass a list to the method.... def get_array(*_): return list(range(10)) @magicclass class A: array = vfield(str, record=False) @set_options(x={"bind": get_array}) def show_array(self, x): old_str = self.array self.array = str(np.asarray(x)) @undo_callback def out(): self.array = old_str return out