Undo and Redo

move cubes around and rewind every move.

▶ Run in browser

Tags: ui undo redo command selection

Undo works by never changing state directly. Each edit is a command that knows how to apply itself and how to reverse itself, and the UndoStack runs it and keeps it. PropertyCommand(cube, "position", old, new, description=...) is the ready-made command for setting one attribute; stack.push(cmd) executes it and records it, stack.undo() restores the old value and stack.redo() applies the new one again. Pushing after an undo discards the redo history, as every editor does.

The stack reports itself: its changed signal fires after every push, undo and redo, can_undo and can_redo say whether each direction has anything left, and undo_description and redo_description name the command at each end. The example’s refresh(), connected to changed, disables the Undo and Redo buttons from can_undo and can_redo and writes both descriptions into the panel at the top left.

Which cube a move applies to comes from a Selection, which holds the selected items and fires selection_changed, which drives the same refresh(); the selected cube glows and swells. Press 1, 2 or 3, or a Cube button, to select, and an arrow key or an arrow button to move it one step. Ctrl+Z undoes, and Ctrl+Shift+Z or Ctrl+Y redoes. The shortcuts are actions with modifier bindings, Key.CTRL + Key.Z, and a binding holding a modifier is exact, so Ctrl+Z does not also fire under Ctrl+Shift+Z. The keys and the buttons call the same methods, so both drive one stack.

Source

  1# /// simvx
  2# tags = ["ui", "undo", "redo", "command", "selection"]
  3# web = { width = 800, height = 600, responsive = true }
  4# ///
  5"""Undo and Redo: move cubes around and rewind every move.
  6
  7Undo works by never changing state directly. Each edit is a command that
  8knows how to apply itself and how to reverse itself, and the `UndoStack`
  9runs it and keeps it. `PropertyCommand(cube, "position", old, new,
 10description=...)` is the ready-made command for setting one attribute;
 11`stack.push(cmd)` executes it and records it, `stack.undo()` restores the
 12old value and `stack.redo()` applies the new one again. Pushing after an
 13undo discards the redo history, as every editor does.
 14
 15The stack reports itself: its `changed` signal fires after every push, undo
 16and redo, `can_undo` and `can_redo` say whether each direction has anything
 17left, and `undo_description` and `redo_description` name the command at
 18each end. The example's `refresh()`, connected to `changed`, disables the
 19Undo and Redo buttons from `can_undo` and `can_redo` and writes both
 20descriptions into the panel at the top left.
 21
 22Which cube a move applies to comes from a `Selection`, which holds the
 23selected items and fires `selection_changed`, which drives the same
 24`refresh()`; the selected cube glows and swells. Press 1, 2 or 3, or a Cube button, to select, and an arrow key or
 25an arrow button to move it one step. Ctrl+Z undoes, and Ctrl+Shift+Z or
 26Ctrl+Y redoes. The shortcuts are actions with modifier bindings,
 27`Key.CTRL + Key.Z`, and a binding holding a modifier is exact, so Ctrl+Z
 28does not also fire under Ctrl+Shift+Z. The keys and the buttons call the
 29same methods, so both drive one stack.
 30"""
 31
 32from functools import partial
 33
 34from simvx.core import (
 35    Anchor,
 36    Axis2D,
 37    Camera3D,
 38    Colour,
 39    DirectionalLight3D,
 40    Key,
 41    Material,
 42    Mesh,
 43    MeshInstance3D,
 44    Node,
 45    PropertyCommand,
 46    Selection,
 47    UndoStack,
 48    Vec3,
 49    WorldEnvironment,
 50    on_input,
 51)
 52from simvx.core.ui import Button, ButtonGroup, HBoxContainer, Label, Panel
 53from simvx.graphics import App
 54
 55CUBE_COLOURS = [(0.9, 0.2, 0.2, 1.0), (0.2, 0.8, 0.2, 1.0), (0.2, 0.4, 0.9, 1.0)]
 56SELECT_ACTIONS = ("select_1", "select_2", "select_3")
 57SELECTED_GLOW, IDLE_GLOW = 2.4, 0.15
 58PANEL_COLOUR = Colour.hex("#181A22")
 59BUTTON_MIN = (38, 34)  # a comfortable touch target; wider where the text needs it
 60
 61
 62class UndoDemo(Node):
 63    input_context = {
 64        "select_1": [Key.KEY_1],
 65        "select_2": [Key.KEY_2],
 66        "select_3": [Key.KEY_3],
 67        "move": [Axis2D.ARROWS],
 68        "undo": [Key.CTRL + Key.Z],
 69        "redo": [Key.CTRL + Key.SHIFT + Key.Z, Key.CTRL + Key.Y],
 70    }
 71
 72    def on_ready(self):
 73        # The camera looks along +Y with +Z as screen up, so the cubes move in
 74        # the X/Z plane, the way the arrows point, above a ledge.
 75        cam = self.add_child(Camera3D(position=(0, -15, 0)))
 76        cam.look_at((0, 0, 0), up=(0, 0, 1))
 77        self.add_child(WorldEnvironment(ambient_light_colour=(0.20, 0.21, 0.25, 1.0)))
 78        sun = self.add_child(DirectionalLight3D(position=(4, -8, 10), colour=(1.0, 0.97, 0.9), intensity=1.5))
 79        sun.look_at((0, 0, 0))
 80        ledge = Material(colour=(0.16, 0.17, 0.21, 1.0), roughness=0.9)
 81        self.add_child(MeshInstance3D(mesh=Mesh.box((24, 8, 0.2)), material=ledge, position=(0, 2, -1.1)))
 82
 83        self.cubes = [
 84            self.add_child(
 85                MeshInstance3D(
 86                    name=f"Cube{i + 1}",
 87                    mesh=Mesh.box(),
 88                    material=Material(colour=colour, roughness=0.4, emissive_colour=colour[:3]),
 89                    position=(4 * i - 4, 0, 0),
 90                )
 91            )
 92            for i, colour in enumerate(CUBE_COLOURS)
 93        ]
 94
 95        self.undo_stack = UndoStack()
 96        self.undo_stack.changed.connect(self.refresh)
 97        self.selection = Selection()
 98        self.selection.selection_changed.connect(self.refresh)
 99
100        info = self.add_child(Panel(anchor=Anchor.TOP_LEFT, inset=16, size=(320, 118)))
101        info.bg_colour = PANEL_COLOUR
102        keys = "Keys 1/2/3 select  -  arrows move\nCtrl+Z undo  -  Ctrl+Shift+Z redo"
103        hint = info.add_child(Label(keys, anchor=Anchor.TOP_LEFT, inset=(12, 10), font_size=13))
104        hint.text_colour = Colour.LIGHT_GRAY
105        self.status = info.add_child(Label("", anchor=Anchor.TOP_LEFT, inset=(12, 56), font_size=13))
106
107        # The same selection, moves and history, for the mouse and a finger.
108        bar = self.add_child(Panel(name="ControlBar", anchor=Anchor.CENTER_BOTTOM, inset=14))
109        bar.bg_colour = PANEL_COLOUR
110        row = bar.add_child(HBoxContainer(anchor=Anchor.FULL_RECT, inset=(10, 9), separation=6))
111        group = ButtonGroup()
112        self.cube_buttons = []
113        for cube in self.cubes:
114            select = partial(self.selection.select, cube)
115            self.cube_buttons.append(
116                row.add_child(Button(cube.name, group=group, min_size=BUTTON_MIN, on_press=select))
117            )
118        for text, dx, dz in (("<", -1, 0), (">", 1, 0), ("^", 0, 1), ("v", 0, -1)):
119            row.add_child(Button(text, min_size=BUTTON_MIN, on_press=partial(self.move_selected, dx, dz)))
120        self.undo_button = row.add_child(Button("Undo", min_size=BUTTON_MIN, on_press=self.undo_stack.undo))
121        self.redo_button = row.add_child(Button("Redo", min_size=BUTTON_MIN, on_press=self.undo_stack.redo))
122        # A Panel does not size to its content, so the bar is sized from the row.
123        bar.size = row.get_minimum_size() + (20, 18)
124
125        self.selection.select(self.cubes[0])
126
127    def move_selected(self, dx: int, dz: int):
128        cube = self.selection.primary
129        old = Vec3(cube.position)
130        new = old + Vec3(dx, 0, dz)
131        self.undo_stack.push(
132            PropertyCommand(cube, "position", old, new, description=f"Move {cube.name} to ({new.x:.0f}, {new.z:.0f})")
133        )
134
135    @on_input(SELECT_ACTIONS)
136    def select_by_key(self, event):
137        self.selection.select(self.cubes[SELECT_ACTIONS.index(event.action)])
138
139    @on_input("move")
140    def move_by_key(self, event):
141        # One step per press. The action is 2D with +y down the screen, and
142        # screen up is +Z in the world.
143        self.move_selected(round(event.value.x), -round(event.value.y))
144
145    @on_input("undo")
146    def undo(self, event):
147        self.undo_stack.undo()
148
149    @on_input("redo")
150    def redo(self, event):
151        self.undo_stack.redo()
152
153    def refresh(self):
154        for cube, button in zip(self.cubes, self.cube_buttons, strict=True):
155            selected = cube in self.selection
156            cube.material.emissive_strength = SELECTED_GLOW if selected else IDLE_GLOW
157            cube.scale = (1.2, 1.2, 1.2) if selected else (1.0, 1.0, 1.0)
158            button.checked = selected
159        self.undo_button.disabled = not self.undo_stack.can_undo
160        self.redo_button.disabled = not self.undo_stack.can_redo
161        undo = self.undo_stack.undo_description or "nothing"
162        redo = self.undo_stack.redo_description or "nothing"
163        self.status.text = f"Selected: {self.selection.primary.name}\nUndo: {undo}\nRedo: {redo}"
164
165
166if __name__ == "__main__":
167    App(title="SimVX Undo Demo", width=800, height=600).run(UndoDemo())