Undo and Redo¶
move cubes around and rewind every move.
▶ Run in browserTags: 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())