|
1 | 1 | # py-undo-stack
|
2 |
| -Pure python Undo / Redo command stack |
| 2 | + |
| 3 | +This library implements a pure python undo / redo stack pattern. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +This library revolves around the concept of UndoStack and UndoCommand and is an |
| 8 | +implementation of the Command pattern. When an action needs to be undoable, an |
| 9 | +UndoCommand is created which encapsulates how to go from an undo state to a redo |
| 10 | +state. |
| 11 | + |
| 12 | +UndoCommands are pushed to the UndoStack and the UndoStack is responsible for |
| 13 | +managing the sequences of undo / redo and the stack size depending on what is |
| 14 | +pushed on the stack. |
| 15 | + |
| 16 | +### UndoGroup |
| 17 | + |
| 18 | +UndoGroup can be used to manage multiple UndoStack. One stack can be active at |
| 19 | +once per UndoGroup. When calling the UndoGroup API, the calls are delegated to |
| 20 | +the active stack for processing. |
| 21 | + |
| 22 | +### Signal |
| 23 | + |
| 24 | +Signal class provides a basic signal / slot implementation. Signals are |
| 25 | +triggered in the UndoStack and UndoGroup to inform that their states have |
| 26 | +changed. The signals are emitted regardless of async or not async variations. |
| 27 | + |
| 28 | +### Compression |
| 29 | + |
| 30 | +UndoCommand provides a mechanism to compress commands with one another. Commands |
| 31 | +pushed to the stack try to merge with previous commands provided that their |
| 32 | +`do_try_merge` methods return True. |
| 33 | + |
| 34 | +When commands are merged, the pushed command is discarded and only the previous |
| 35 | +command is kept containing both commands information. |
| 36 | + |
| 37 | +### Obsolete |
| 38 | + |
| 39 | +Commands can be marked as obsolete. When a command is obsolete it will be |
| 40 | +removed from the stack automatically. |
| 41 | + |
| 42 | +### Async |
| 43 | + |
| 44 | +UndoStack and UndoGroup provide async variations for their `push` `undo` and |
| 45 | +`redo` methods. UndoCommand provides async variations for its `undo` and `redo` |
| 46 | +methods. |
| 47 | + |
| 48 | +When async undo / redo methods are called, the actions are awaited. |
| 49 | + |
| 50 | +## Examples |
| 51 | + |
| 52 | +```python |
| 53 | +""" |
| 54 | +This example shows an example of undo / redo for mutating lists and dicts. |
| 55 | +""" |
| 56 | + |
| 57 | +import logging |
| 58 | +from contextlib import contextmanager |
| 59 | +from copy import deepcopy |
| 60 | + |
| 61 | +from undo_stack import UndoCommand, UndoStack |
| 62 | + |
| 63 | +logging.basicConfig(level=logging.DEBUG) |
| 64 | + |
| 65 | + |
| 66 | +class ListDictUndoCommand(UndoCommand): |
| 67 | + """ |
| 68 | + We create a command which will save the state of the object before and after. |
| 69 | + We also create a utility context manager to make usage easier in the code. |
| 70 | + """ |
| 71 | + |
| 72 | + def __init__(self, obj_before, obj_after, obj): |
| 73 | + super().__init__() |
| 74 | + self._before = obj_before |
| 75 | + self._after = obj_after |
| 76 | + self._obj = obj |
| 77 | + |
| 78 | + def is_obsolete(self) -> bool: |
| 79 | + """ |
| 80 | + If the before and after states are strictly the same, then we can discard this undo / redo. |
| 81 | + """ |
| 82 | + return self._before == self._after |
| 83 | + |
| 84 | + def _restore(self, state) -> None: |
| 85 | + """Generic method to restore the state while keeping the original instance.""" |
| 86 | + if isinstance(self._obj, dict): |
| 87 | + self._obj.clear() |
| 88 | + self._obj.update(state) |
| 89 | + elif isinstance(self._obj, list): |
| 90 | + self._obj.clear() |
| 91 | + self._obj.extend(state) |
| 92 | + else: |
| 93 | + raise NotImplementedError() |
| 94 | + |
| 95 | + def undo(self) -> None: |
| 96 | + """Restore the previous state.""" |
| 97 | + self._restore(self._before) |
| 98 | + |
| 99 | + def redo(self) -> None: |
| 100 | + """Reapply the modified state.""" |
| 101 | + self._restore(self._after) |
| 102 | + |
| 103 | + @classmethod |
| 104 | + @contextmanager |
| 105 | + def save_for_undo(cls, undo_stack: UndoStack, obj): |
| 106 | + """ |
| 107 | + In the context manager, we save the state before, and state after yield. |
| 108 | + The before and after states will allow us to restore the state of the object. |
| 109 | + """ |
| 110 | + before = deepcopy(obj) |
| 111 | + yield |
| 112 | + after = deepcopy(obj) |
| 113 | + undo_stack.push(cls(before, after, obj)) |
| 114 | + |
| 115 | + |
| 116 | +""" |
| 117 | +In our basic application, we create one undo stack. |
| 118 | +With this undo stack we will monitor the changes of a list and a dict. |
| 119 | +""" |
| 120 | + |
| 121 | +undo_stack = UndoStack() |
| 122 | + |
| 123 | +a_list = [] |
| 124 | +a_dict = {} |
| 125 | + |
| 126 | +# With our context manager, we can track changes to the dict and list |
| 127 | +with ListDictUndoCommand.save_for_undo(undo_stack, a_dict): |
| 128 | + a_dict["hello"] = "world" |
| 129 | + |
| 130 | +with ListDictUndoCommand.save_for_undo(undo_stack, a_list): |
| 131 | + a_list.append(42) |
| 132 | + |
| 133 | +logging.info(undo_stack.n_commands()) |
| 134 | +# >>> 2 |
| 135 | + |
| 136 | +logging.info(undo_stack.can_undo()) |
| 137 | +# >>> True |
| 138 | + |
| 139 | +# Our undo stack is ordered. Undoing will undo the latest undo on the stack which is the list append. |
| 140 | +undo_stack.undo() |
| 141 | +logging.info(a_list) |
| 142 | +# >>> [] |
| 143 | + |
| 144 | +undo_stack.redo() |
| 145 | +logging.info(a_list) |
| 146 | +# >>> [42] |
| 147 | + |
| 148 | +# We can undo further to reset the content of the dict |
| 149 | +undo_stack.undo() |
| 150 | +undo_stack.undo() |
| 151 | + |
| 152 | +logging.info(a_dict) |
| 153 | +# >>> {} |
| 154 | + |
| 155 | +undo_stack.redo() |
| 156 | +logging.info(a_dict) |
| 157 | +# >>> {"hello": "world"} |
| 158 | + |
| 159 | +# Pushing in the stack will make the list modification obsolete |
| 160 | +with ListDictUndoCommand.save_for_undo(undo_stack, a_dict): |
| 161 | + a_dict["hello"] = str(reversed("world")) |
| 162 | + |
| 163 | +logging.info(undo_stack.can_redo()) |
| 164 | +# >>> False |
| 165 | +``` |
| 166 | + |
| 167 | +## Acknowledgments |
| 168 | + |
| 169 | +This implementation is inspired by Qt's |
| 170 | +[QUndoStack, QUndoCommand, QUndoGroup](https://doc.qt.io/qt-6/qundostack.html). |
| 171 | +For usage in Qt based application, direct usage of Qt's classes is recommended. |
0 commit comments