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

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