Undo/Redo demo¶
move cubes and rewind with Ctrl+Z / Ctrl+Shift+Z.
▶ Run in browserTags: ui undo redo command selection
Select a cube, nudge it around the board, then rewind and replay every move
through a real :class:UndoStack. An anchored HUD shows the current selection
and the top of the undo/redo history, and the whole demo is playable with the
mouse or touch via the on-screen selection, movement and undo/redo buttons.
Controls: 1 / 2 / 3 Select cube (or click the selection buttons) Arrow keys Move selected cube (Left/Right = X, Up/Down = Z) Ctrl+Z Undo last move Ctrl+Shift+Z / Ctrl+Y Redo Escape Quit Mouse / touch Use the on-screen buttons for select, move and undo/redo
Run with: uv run python examples/features/ui/undo.py
Source¶
1"""Undo/Redo demo -- move cubes and rewind with Ctrl+Z / Ctrl+Shift+Z.
2
3Select a cube, nudge it around the board, then rewind and replay every move
4through a real :class:`UndoStack`. An anchored HUD shows the current selection
5and the top of the undo/redo history, and the whole demo is playable with the
6mouse or touch via the on-screen selection, movement and undo/redo buttons.
7
8# /// simvx
9# tags = ["ui", "undo", "redo", "command", "selection"]
10# web = { root = "UndoDemo", width = 800, height = 600, responsive = true }
11# ///
12
13Controls:
14 1 / 2 / 3 Select cube (or click the selection buttons)
15 Arrow keys Move selected cube (Left/Right = X, Up/Down = Z)
16 Ctrl+Z Undo last move
17 Ctrl+Shift+Z / Ctrl+Y Redo
18 Escape Quit
19 Mouse / touch Use the on-screen buttons for select, move and undo/redo
20
21Run with:
22 uv run python examples/features/ui/undo.py
23"""
24
25from simvx.core import (
26 AnchorPreset,
27 Button,
28 Camera3D,
29 Colour,
30 DirectionalLight3D,
31 Input,
32 InputMap,
33 Key,
34 Label,
35 Material,
36 Mesh,
37 MeshInstance3D,
38 Node,
39 Panel,
40 PropertyCommand,
41 Selection,
42 UndoStack,
43 Vec2,
44 Vec3,
45 WorldEnvironment,
46)
47from simvx.graphics import App
48
49MOVE_STEP = 1.0
50COOLDOWN = 0.15 # seconds between accepted key repeats
51
52CUBE_COLOURS = [
53 (0.9, 0.2, 0.2, 1.0), # red
54 (0.2, 0.8, 0.2, 1.0), # green
55 (0.2, 0.4, 0.9, 1.0), # blue
56]
57CUBE_POSITIONS = [
58 Vec3(-4, 0, 0),
59 Vec3(0, 0, 0),
60 Vec3(4, 0, 0),
61]
62SELECTED_EMISSIVE = 2.4 # emissive strength for the highlighted cube
63IDLE_EMISSIVE = 0.15 # faint glow so idle cubes still read against the scene
64
65
66class UndoDemo(Node):
67 def on_ready(self):
68 InputMap.add_action("quit", [Key.ESCAPE])
69
70 # Camera looking down the Y axis: screen X maps to world X, screen Y to
71 # world Z, so the arrow keys move cubes in the plane you actually see.
72 cam = Camera3D(position=(0, -15, 0))
73 cam.look_at((0, 0, 0), up=(0, 0, 1))
74 self.add_child(cam)
75
76 # Light the scene so the cubes read as solid objects rather than a dim
77 # void: ambient fill plus a key directional light.
78 env = self.add_child(WorldEnvironment())
79 env.ambient_light_colour = (0.20, 0.21, 0.25, 1.0)
80 sun = DirectionalLight3D(position=(4, -8, 10))
81 sun.colour = (1.0, 0.97, 0.9)
82 sun.intensity = 1.5
83 sun.look_at((0, 0, 0))
84 self.add_child(sun)
85
86 # A ground slab so the cubes sit on something instead of floating.
87 self.add_child(MeshInstance3D(
88 mesh=Mesh.cube(),
89 material=Material(colour=(0.16, 0.17, 0.21, 1.0), roughness=0.9, metallic=0.0),
90 position=(0, 2, -1.1),
91 scale=(24, 8, 0.2),
92 ))
93
94 # Create cubes, each with its own material so we can highlight one.
95 cube_mesh = Mesh.cube()
96 self.cubes = []
97 for i in range(3):
98 cube = MeshInstance3D(
99 name=f"Cube{i + 1}",
100 mesh=cube_mesh,
101 material=Material(colour=CUBE_COLOURS[i], roughness=0.4, metallic=0.05),
102 position=tuple(CUBE_POSITIONS[i]),
103 )
104 self.add_child(cube)
105 self.cubes.append(cube)
106
107 # Undo system
108 self.undo_stack = UndoStack()
109 self.undo_stack.changed.connect(self._on_stack_changed)
110
111 # Selection
112 self.selection = Selection()
113 self.selection.selection_changed.connect(self._on_selection_changed)
114
115 self._cooldown_timer = 0.0
116
117 self._build_hud()
118 self.selection.select(self.cubes[0])
119 self._on_stack_changed()
120
121 # -- HUD -----------------------------------------------------------------
122
123 def _build_hud(self):
124 # Info panel, top-left: controls hint + live selection/history readout.
125 panel = Panel(name="InfoPanel")
126 panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
127 panel.margin_left = 16
128 panel.margin_top = 16
129 panel.size = Vec2(320, 118)
130 panel.bg_colour = Colour.hex("#181A22")
131 self.add_child(panel)
132
133 hint = Label(
134 "Keys 1/2/3 select - arrows move\n"
135 "Ctrl+Z undo - Ctrl+Shift+Z redo",
136 )
137 hint.set_anchor_preset(AnchorPreset.TOP_LEFT)
138 hint.margin_left = 12
139 hint.margin_top = 10
140 hint.font_size = 13.0
141 hint.text_colour = Colour.LIGHT_GRAY
142 panel.add_child(hint)
143
144 self._status = Label("")
145 self._status.set_anchor_preset(AnchorPreset.TOP_LEFT)
146 self._status.margin_left = 12
147 self._status.margin_top = 56
148 self._status.font_size = 13.0
149 self._status.text_colour = Colour.WHITE
150 panel.add_child(self._status)
151
152 # Bottom control bar: a fixed-size anchored panel holding the mouse/touch
153 # buttons, laid out at local positions so it stays centred on resize.
154 bar = Panel(name="ControlBar")
155 bar.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
156 bar_w, bar_h = 616.0, 52.0
157 bar.size = Vec2(bar_w, bar_h)
158 bar.margin_left = -bar_w / 2
159 bar.margin_right = bar_w / 2
160 bar.margin_top = -bar_h - 14
161 bar.margin_bottom = -14
162 bar.bg_colour = Colour.hex("#181A22")
163 self.add_child(bar)
164
165 x = 10.0
166 self._select_buttons = []
167 for i in range(3):
168 btn = Button(f"Cube {i + 1}", on_press=lambda i=i: self.selection.select(self.cubes[i]))
169 btn.font_size = 13.0
170 btn.size = Vec2(72, 34)
171 btn.position = Vec2(x, 9)
172 bar.add_child(btn)
173 self._select_buttons.append(btn)
174 x += 78
175
176 x += 12
177 moves = [("<", Vec3(-MOVE_STEP, 0, 0)), (">", Vec3(MOVE_STEP, 0, 0)),
178 ("^", Vec3(0, 0, MOVE_STEP)), ("v", Vec3(0, 0, -MOVE_STEP))]
179 for label, direction in moves:
180 btn = Button(label, on_press=lambda d=direction: self._move_selected(d))
181 btn.font_size = 15.0
182 btn.size = Vec2(38, 34)
183 btn.position = Vec2(x, 9)
184 bar.add_child(btn)
185 x += 44
186
187 x += 12
188 self._undo_btn = Button("Undo", on_press=lambda: self.undo_stack.undo())
189 self._undo_btn.font_size = 13.0
190 self._undo_btn.size = Vec2(66, 34)
191 self._undo_btn.position = Vec2(x, 9)
192 bar.add_child(self._undo_btn)
193 x += 72
194 self._redo_btn = Button("Redo", on_press=lambda: self.undo_stack.redo())
195 self._redo_btn.font_size = 13.0
196 self._redo_btn.size = Vec2(66, 34)
197 self._redo_btn.position = Vec2(x, 9)
198 bar.add_child(self._redo_btn)
199
200 # -- Actions -------------------------------------------------------------
201
202 def _move_selected(self, direction: Vec3):
203 if self.selection.empty:
204 return
205 cube = self.selection.primary
206 old_pos = tuple(Vec3(cube.position))
207 new_pos = tuple(Vec3(cube.position) + direction)
208 cmd = PropertyCommand(
209 cube,
210 "position",
211 old_pos,
212 new_pos,
213 description=f"Move {cube.name} to ({new_pos[0]:.0f}, {new_pos[2]:.0f})",
214 )
215 self.undo_stack.push(cmd)
216
217 def on_update(self, dt):
218 if Input.is_action_just_pressed("quit"):
219 self.app.quit()
220 return
221
222 self._cooldown_timer = max(0.0, self._cooldown_timer - dt)
223
224 # Cube selection (1-3)
225 for i, key in enumerate((Key.KEY_1, Key.KEY_2, Key.KEY_3)):
226 if Input.is_key_just_pressed(key):
227 self.selection.select(self.cubes[i])
228
229 ctrl = Input.is_key_pressed(Key.LEFT_CONTROL) or Input.is_key_pressed(Key.RIGHT_CONTROL)
230 shift = Input.is_key_pressed(Key.LEFT_SHIFT) or Input.is_key_pressed(Key.RIGHT_SHIFT)
231
232 # Undo / Redo (react on just-pressed only)
233 if ctrl and Input.is_key_just_pressed(Key.Z):
234 if shift:
235 self.undo_stack.redo()
236 else:
237 self.undo_stack.undo()
238 return
239 if ctrl and Input.is_key_just_pressed(Key.Y):
240 self.undo_stack.redo()
241 return
242
243 # Movement (with cooldown for key repeat)
244 if self._cooldown_timer > 0 or self.selection.empty:
245 return
246
247 direction = Vec3(0, 0, 0)
248 if Input.is_key_pressed(Key.LEFT):
249 direction = Vec3(-MOVE_STEP, 0, 0)
250 elif Input.is_key_pressed(Key.RIGHT):
251 direction = Vec3(MOVE_STEP, 0, 0)
252 elif Input.is_key_pressed(Key.UP):
253 direction = Vec3(0, 0, MOVE_STEP)
254 elif Input.is_key_pressed(Key.DOWN):
255 direction = Vec3(0, 0, -MOVE_STEP)
256
257 if direction.length() > 0:
258 self._move_selected(direction)
259 self._cooldown_timer = COOLDOWN
260
261 # -- Signal hooks --------------------------------------------------------
262
263 def _on_selection_changed(self):
264 # Highlight the selected cube: brighten its emissive glow and swell it
265 # slightly so the current target is unmistakable.
266 for cube in self.cubes:
267 selected = self.selection.is_selected(cube)
268 base = cube.material.colour
269 strength = SELECTED_EMISSIVE if selected else IDLE_EMISSIVE
270 cube.material.emissive_colour = (base[0], base[1], base[2], strength)
271 cube.scale = (1.2, 1.2, 1.2) if selected else (1.0, 1.0, 1.0)
272 for i, btn in enumerate(self._select_buttons):
273 btn.set_visual_state_override(
274 Button.VisualState.PRESSED if self.selection.is_selected(self.cubes[i]) else None
275 )
276 self._refresh_status()
277
278 def _on_stack_changed(self):
279 self._undo_btn.disabled = not self.undo_stack.can_undo
280 self._redo_btn.disabled = not self.undo_stack.can_redo
281 self._refresh_status()
282
283 def _refresh_status(self):
284 primary = self.selection.primary
285 selected = primary.name if primary is not None else "none"
286 undo = self.undo_stack.undo_description or "nothing"
287 redo = self.undo_stack.redo_description or "nothing"
288 self._status.text = (
289 f"Selected: {selected}\n"
290 f"Undo: {undo}\n"
291 f"Redo: {redo}"
292 )
293
294
295if __name__ == "__main__":
296 app = App(title="SimVX Undo Demo", width=800, height=600)
297 app.run(UndoDemo())