Undo/Redo demo¶

move cubes and rewind with Ctrl+Z / Ctrl+Shift+Z.

â–¶ Run in browser

Tags: 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())