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 uv run python examples/features/ui/undo.py –test
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 uv run python examples/features/ui/undo.py --test
24"""
25
26from simvx.core import (
27 Camera3D,
28 Colour,
29 DirectionalLight3D,
30 Input,
31 Key,
32 Material,
33 Mesh,
34 MeshInstance3D,
35 Node,
36 PropertyCommand,
37 Selection,
38 UndoStack,
39 Vec2,
40 Vec3,
41 WorldEnvironment,
42)
43from simvx.core.ui import AnchorPreset, Button, HBoxContainer, Label, Panel
44from simvx.graphics import App
45
46MOVE_STEP = 1.0
47COOLDOWN = 0.15 # seconds between accepted key repeats
48
49CUBE_COLOURS = [
50 (0.9, 0.2, 0.2, 1.0), # red
51 (0.2, 0.8, 0.2, 1.0), # green
52 (0.2, 0.4, 0.9, 1.0), # blue
53]
54CUBE_POSITIONS = [
55 Vec3(-4, 0, 0),
56 Vec3(0, 0, 0),
57 Vec3(4, 0, 0),
58]
59SELECTED_EMISSIVE = 2.4 # emissive strength for the highlighted cube
60IDLE_EMISSIVE = 0.15 # faint glow so idle cubes still read against the scene
61
62BUTTON_H = 34.0 # height of every button in the bottom control bar
63BAR_PAD_X = 10.0
64BAR_PAD_Y = 9.0
65
66
67class UndoDemo(Node):
68 input_actions = {"quit": [Key.ESCAPE]}
69
70 def on_ready(self):
71 # Camera looking down the Y axis: screen X maps to world X, screen Y to
72 # world Z, so the arrow keys move cubes in the plane you actually see.
73 cam = Camera3D(position=(0, -15, 0))
74 cam.look_at((0, 0, 0), up=(0, 0, 1))
75 self.add_child(cam)
76
77 # Light the scene so the cubes read as solid objects rather than a dim
78 # void: ambient fill plus a key directional light.
79 env = self.add_child(WorldEnvironment())
80 env.ambient_light_colour = (0.20, 0.21, 0.25, 1.0)
81 sun = DirectionalLight3D(position=(4, -8, 10))
82 sun.colour = (1.0, 0.97, 0.9)
83 sun.intensity = 1.5
84 sun.look_at((0, 0, 0))
85 self.add_child(sun)
86
87 # A ground slab so the cubes sit on something instead of floating.
88 self.add_child(
89 MeshInstance3D(
90 mesh=Mesh.cube(),
91 material=Material(colour=(0.16, 0.17, 0.21, 1.0), roughness=0.9, metallic=0.0),
92 position=(0, 2, -1.1),
93 scale=(24, 8, 0.2),
94 )
95 )
96
97 # Create cubes, each with its own material so we can highlight one.
98 cube_mesh = Mesh.cube()
99 self.cubes = []
100 for i in range(3):
101 cube = MeshInstance3D(
102 name=f"Cube{i + 1}",
103 mesh=cube_mesh,
104 material=Material(
105 colour=CUBE_COLOURS[i],
106 roughness=0.4,
107 metallic=0.05,
108 emissive_colour=CUBE_COLOURS[i][:3],
109 emissive_strength=IDLE_EMISSIVE,
110 ),
111 position=tuple(CUBE_POSITIONS[i]),
112 )
113 self.add_child(cube)
114 self.cubes.append(cube)
115
116 # Undo system
117 self.undo_stack = UndoStack()
118 self.undo_stack.changed.connect(self._on_stack_changed)
119
120 # Selection
121 self.selection = Selection()
122 self.selection.selection_changed.connect(self._on_selection_changed)
123
124 self._cooldown_timer = 0.0
125
126 self._build_hud()
127 self.selection.select(self.cubes[0])
128 self._on_stack_changed()
129
130 # -- HUD -----------------------------------------------------------------
131
132 def _build_hud(self):
133 # Info panel, top-left: controls hint + live selection/history readout.
134 panel = Panel(name="InfoPanel")
135 panel.set_anchor_preset(AnchorPreset.TOP_LEFT)
136 panel.margin_left = 16
137 panel.margin_top = 16
138 panel.size = Vec2(320, 118)
139 panel.bg_colour = Colour.hex("#181A22")
140 self.add_child(panel)
141
142 hint = Label(
143 "Keys 1/2/3 select - arrows move\n" "Ctrl+Z undo - Ctrl+Shift+Z redo",
144 )
145 hint.set_anchor_preset(AnchorPreset.TOP_LEFT)
146 hint.margin_left = 12
147 hint.margin_top = 10
148 hint.font_size = 13.0
149 hint.text_colour = Colour.LIGHT_GRAY
150 panel.add_child(hint)
151
152 self._status = Label("")
153 self._status.set_anchor_preset(AnchorPreset.TOP_LEFT)
154 self._status.margin_left = 12
155 self._status.margin_top = 56
156 self._status.font_size = 13.0
157 self._status.text_colour = Colour.WHITE
158 panel.add_child(self._status)
159
160 # Bottom control bar: a panel holding the mouse/touch buttons. Nested
161 # HBoxContainers place the buttons, so the demo never writes a button
162 # coordinate; the bar itself is anchored bottom-centre and stays centred
163 # on resize.
164 bar = Panel(name="ControlBar")
165 bar.set_anchor_preset(AnchorPreset.CENTER_BOTTOM)
166 bar.bg_colour = Colour.hex("#181A22")
167 self.add_child(bar)
168
169 row = HBoxContainer(name="BarRow")
170 row.set_anchor_preset(AnchorPreset.FULL_RECT)
171 row.margin_left = BAR_PAD_X
172 row.margin_right = BAR_PAD_X
173 row.margin_top = BAR_PAD_Y
174 row.margin_bottom = BAR_PAD_Y
175 row.separation = 18.0 # gap between the three button groups
176 bar.add_child(row)
177
178 select_group = HBoxContainer(name="SelectButtons")
179 select_group.separation = 6.0
180 row.add_child(select_group)
181 self._select_buttons = []
182 for i in range(3):
183 btn = Button(f"Cube {i + 1}", on_press=lambda i=i: self.selection.select(self.cubes[i]))
184 btn.font_size = 13.0
185 btn.size = Vec2(72, BUTTON_H)
186 select_group.add_child(btn)
187 self._select_buttons.append(btn)
188
189 move_group = HBoxContainer(name="MoveButtons")
190 move_group.separation = 6.0
191 row.add_child(move_group)
192 moves = [
193 ("<", Vec3(-MOVE_STEP, 0, 0)),
194 (">", Vec3(MOVE_STEP, 0, 0)),
195 ("^", Vec3(0, 0, MOVE_STEP)),
196 ("v", Vec3(0, 0, -MOVE_STEP)),
197 ]
198 self._move_buttons = {}
199 for label, direction in moves:
200 btn = Button(label, on_press=lambda d=direction: self._move_selected(d))
201 btn.font_size = 15.0
202 btn.size = Vec2(38, BUTTON_H)
203 move_group.add_child(btn)
204 self._move_buttons[label] = btn
205
206 history_group = HBoxContainer(name="HistoryButtons")
207 history_group.separation = 6.0
208 row.add_child(history_group)
209 self._undo_btn = Button("Undo", on_press=self.undo_stack.undo)
210 self._undo_btn.font_size = 13.0
211 self._undo_btn.size = Vec2(66, BUTTON_H)
212 history_group.add_child(self._undo_btn)
213 self._redo_btn = Button("Redo", on_press=self.undo_stack.redo)
214 self._redo_btn.font_size = 13.0
215 self._redo_btn.size = Vec2(66, BUTTON_H)
216 history_group.add_child(self._redo_btn)
217
218 # Size the bar to the row it holds (button widths grow with their text),
219 # then offset it from the bottom-centre anchor. The bar is a Panel, not a
220 # container, so the row is asked to reflow into its new rect.
221 bar_w = row.get_minimum_size().x + 2 * BAR_PAD_X
222 bar_h = BUTTON_H + 2 * BAR_PAD_Y
223 bar.size = Vec2(bar_w, bar_h)
224 bar.margin_left = -bar_w / 2
225 bar.margin_right = bar_w / 2
226 bar.margin_top = -bar_h - 14
227 bar.margin_bottom = -14
228 row.mark_layout_dirty()
229
230 # -- Actions -------------------------------------------------------------
231
232 def _move_selected(self, direction: Vec3):
233 if self.selection.empty:
234 return
235 cube = self.selection.primary
236 old_pos = tuple(Vec3(cube.position))
237 new_pos = tuple(Vec3(cube.position) + direction)
238 cmd = PropertyCommand(
239 cube,
240 "position",
241 old_pos,
242 new_pos,
243 description=f"Move {cube.name} to ({new_pos[0]:.0f}, {new_pos[2]:.0f})",
244 )
245 self.undo_stack.push(cmd)
246
247 def on_update(self, dt):
248 if Input.is_action_just_pressed("quit"):
249 self.app.quit()
250 return
251
252 self._cooldown_timer = max(0.0, self._cooldown_timer - dt)
253
254 # Cube selection (1-3)
255 for i, key in enumerate((Key.KEY_1, Key.KEY_2, Key.KEY_3)):
256 if Input.is_key_just_pressed(key):
257 self.selection.select(self.cubes[i])
258
259 ctrl = Input.is_key_pressed(Key.LEFT_CONTROL) or Input.is_key_pressed(Key.RIGHT_CONTROL)
260 shift = Input.is_key_pressed(Key.LEFT_SHIFT) or Input.is_key_pressed(Key.RIGHT_SHIFT)
261
262 # Undo / Redo (react on just-pressed only)
263 if ctrl and Input.is_key_just_pressed(Key.Z):
264 if shift:
265 self.undo_stack.redo()
266 else:
267 self.undo_stack.undo()
268 return
269 if ctrl and Input.is_key_just_pressed(Key.Y):
270 self.undo_stack.redo()
271 return
272
273 # Movement (with cooldown for key repeat)
274 if self._cooldown_timer > 0 or self.selection.empty:
275 return
276
277 direction = Vec3(0, 0, 0)
278 if Input.is_key_pressed(Key.LEFT):
279 direction = Vec3(-MOVE_STEP, 0, 0)
280 elif Input.is_key_pressed(Key.RIGHT):
281 direction = Vec3(MOVE_STEP, 0, 0)
282 elif Input.is_key_pressed(Key.UP):
283 direction = Vec3(0, 0, MOVE_STEP)
284 elif Input.is_key_pressed(Key.DOWN):
285 direction = Vec3(0, 0, -MOVE_STEP)
286
287 if direction.length() > 0:
288 self._move_selected(direction)
289 self._cooldown_timer = COOLDOWN
290
291 # -- Signal hooks --------------------------------------------------------
292
293 def _on_selection_changed(self):
294 # Highlight the selected cube: brighten its emissive glow and swell it
295 # slightly so the current target is unmistakable.
296 for cube in self.cubes:
297 selected = self.selection.is_selected(cube)
298 cube.material.emissive_strength = SELECTED_EMISSIVE if selected else IDLE_EMISSIVE
299 cube.scale = (1.2, 1.2, 1.2) if selected else (1.0, 1.0, 1.0)
300 for i, btn in enumerate(self._select_buttons):
301 btn.set_visual_state_override(
302 Button.VisualState.PRESSED if self.selection.is_selected(self.cubes[i]) else None
303 )
304 self._refresh_status()
305
306 def _on_stack_changed(self):
307 self._undo_btn.disabled = not self.undo_stack.can_undo
308 self._redo_btn.disabled = not self.undo_stack.can_redo
309 self._refresh_status()
310
311 def _refresh_status(self):
312 primary = self.selection.primary
313 selected = primary.name if primary is not None else "none"
314 undo = self.undo_stack.undo_description or "nothing"
315 redo = self.undo_stack.redo_description or "nothing"
316 self._status.text = f"Selected: {selected}\n" f"Undo: {undo}\n" f"Redo: {redo}"
317
318
319def _selftest() -> bool:
320 """Headless: move a cube with the keyboard, rewind it, and do it again by mouse.
321
322 Every command reaches the stack the way a player puts it there -- a key held
323 for a frame, or a click on a button's own rectangle -- and the cube's world
324 position is what the undo is measured by, not the stack's depth. The two
325 routes are checked to be the same route: the buttons and the keys drive one
326 ``UndoStack`` and one ``Selection``.
327 """
328 from simvx.core import MouseButton
329 from simvx.core.testing import InputSimulator
330 from simvx.graphics.testing import assert_not_blank, save_png
331
332 app = App(title="SimVX Undo Demo", width=800, height=600, visible=False)
333 scene = UndoDemo(name="UndoDemo")
334 sim = InputSimulator()
335 seen: dict[str, object] = {}
336
337 SELECT, MOVE_ONE, MOVE_TWO = 4, 10, 22
338 UNDO_KEY, REDO_KEY = 34, 44
339 CLICK_CUBE3, CLICK_RIGHT, CLICK_UNDO = 54, 60, 66
340 HELD = 2 # frames a key is held: one move, then the cooldown swallows the repeat
341
342 held: list[Key] = []
343
344 def combo(*keys: Key) -> None:
345 """Press a chord; the frame after, it is released."""
346 for key in keys:
347 sim.press_key(key)
348 held.extend(keys)
349
350 def click(control) -> None:
351 x, y, w, h = control.get_global_rect()
352 sim.click((x + w / 2, y + h / 2), MouseButton.LEFT)
353
354 def on_frame(idx: int, _t: float) -> bool:
355 if idx == 0:
356 seen["start"] = [tuple(c.position) for c in scene.cubes]
357 seen["buttons_at_rest"] = (scene._undo_btn.disabled, scene._redo_btn.disabled)
358 elif idx == SELECT:
359 sim.press_key(Key.KEY_2)
360 elif idx == SELECT + 1:
361 sim.release_key(Key.KEY_2)
362 seen["selected"] = scene.selection.primary.name
363 seen["glow"] = [float(c.material.emissive_strength) for c in scene.cubes]
364 seen["pinned"] = [b.visual_state_override for b in scene._select_buttons]
365 elif idx in (MOVE_ONE, MOVE_TWO):
366 sim.press_key(Key.RIGHT)
367 elif idx in (MOVE_ONE + HELD, MOVE_TWO + HELD):
368 sim.release_key(Key.RIGHT)
369 seen[f"after_move_{idx}"] = float(scene.cubes[1].position.x)
370 elif idx == UNDO_KEY:
371 combo(Key.LEFT_CONTROL, Key.Z)
372 elif idx == UNDO_KEY + 2:
373 seen["after_undo"] = float(scene.cubes[1].position.x)
374 seen["buttons_mid_history"] = (scene._undo_btn.disabled, scene._redo_btn.disabled)
375 elif idx == REDO_KEY:
376 combo(Key.LEFT_CONTROL, Key.LEFT_SHIFT, Key.Z)
377 elif idx == REDO_KEY + 2:
378 seen["after_redo"] = float(scene.cubes[1].position.x)
379 seen["status"] = scene._status.text
380 elif idx == CLICK_CUBE3:
381 click(scene._select_buttons[2])
382 elif idx == CLICK_CUBE3 + 1:
383 seen["clicked_selection"] = scene.selection.primary.name
384 elif idx == CLICK_RIGHT:
385 click(scene._move_buttons[">"])
386 elif idx == CLICK_RIGHT + 1:
387 seen["after_click_move"] = float(scene.cubes[2].position.x)
388 elif idx == CLICK_UNDO:
389 click(scene._undo_btn)
390 elif idx == CLICK_UNDO + 1:
391 seen["after_click_undo"] = float(scene.cubes[2].position.x)
392 elif held:
393 for key in held:
394 sim.release_key(key)
395 held.clear()
396 return True
397
398 frames = app.run_headless(scene, frames=CLICK_UNDO + 10, on_frame=on_frame, capture_frames=[CLICK_UNDO + 5])
399 assert_not_blank(frames[0])
400 save_png(frames[0], "/tmp/undo_test.png")
401
402 ok = True
403
404 def check(label: str, passed: bool, detail: str) -> None:
405 nonlocal ok
406 ok = ok and passed
407 print(f"{'ok ' if passed else 'FAIL'} {label}: {detail}")
408
409 check(
410 "nothing is undoable before the first move",
411 seen["buttons_at_rest"] == (True, True),
412 "both history buttons start disabled",
413 )
414 check(
415 "key 2 selects the middle cube, and only it is lit and enlarged",
416 seen["selected"] == "Cube2"
417 and seen["glow"] == [IDLE_EMISSIVE, SELECTED_EMISSIVE, IDLE_EMISSIVE]
418 and [i for i, s in enumerate(seen["pinned"]) if s is not None] == [1],
419 f"{seen['selected']} glowing at {seen['glow'][1]:.2f} against {seen['glow'][0]:.2f}",
420 )
421
422 start_x = seen["start"][1][0]
423 one, two = seen[f"after_move_{MOVE_ONE + HELD}"], seen[f"after_move_{MOVE_TWO + HELD}"]
424 check(
425 "each press of Right moves it exactly one step, and the cooldown stops it repeating",
426 abs(one - (start_x + MOVE_STEP)) < 1e-5 and abs(two - (start_x + 2 * MOVE_STEP)) < 1e-5,
427 f"x {start_x:.0f} -> {one:.0f} -> {two:.0f} for two presses of {MOVE_STEP:.0f}",
428 )
429 check(
430 "Ctrl+Z puts the last move back",
431 abs(seen["after_undo"] - one) < 1e-5,
432 f"x {two:.0f} -> {seen['after_undo']:.0f}",
433 )
434 check(
435 "and with history on both sides, both buttons are live",
436 seen["buttons_mid_history"] == (False, False),
437 "Undo and Redo both enabled",
438 )
439 check(
440 "Ctrl+Shift+Z replays it",
441 abs(seen["after_redo"] - two) < 1e-5,
442 f"x {seen['after_undo']:.0f} -> {seen['after_redo']:.0f}",
443 )
444 check(
445 "the status line names the selection and both ends of the history",
446 "Cube2" in seen["status"] and "Undo: Move Cube2" in seen["status"] and "Redo: nothing" in seen["status"],
447 seen["status"].replace("\n", " | "),
448 )
449 check(
450 "the on-screen buttons drive the same selection, the same move and the same stack",
451 seen["clicked_selection"] == "Cube3"
452 and abs(seen["after_click_move"] - (seen["start"][2][0] + MOVE_STEP)) < 1e-5
453 and abs(seen["after_click_undo"] - seen["start"][2][0]) < 1e-5,
454 f"Cube3 x {seen['start'][2][0]:.0f} -> {seen['after_click_move']:.0f} -> {seen['after_click_undo']:.0f}",
455 )
456
457 print("screenshot: /tmp/undo_test.png")
458 print("SELFTEST:", "PASS" if ok else "FAIL")
459 return ok
460
461
462if __name__ == "__main__":
463 import sys
464
465 if "--test" in sys.argv:
466 sys.exit(0 if _selftest() else 1)
467 app = App(title="SimVX Undo Demo", width=800, height=600)
468 app.run(UndoDemo())