Source code for simvx.editor.demo_steps

"""Editor-specific demo step types for scripted editor demos.

Provides high-level declarative steps (AddNode, SetProperty, SelectNode, etc.)
that operate through EditorState methods rather than raw UI pixel clicks.
Eliminates the need for helper functions and hardcoded layout coordinates.

Usage:
    from simvx.editor.demo_steps import register_editor_steps, AddNode, SetProperty, ...
    register_editor_steps()
    steps = [AddNode("Label", parent_path="Layout", name="Title"), ...]
"""


from __future__ import annotations

import logging
from dataclasses import dataclass, field

from simvx.core import Node
from simvx.core.scripted_demo import DemoRunner

log = logging.getLogger(__name__)


# ============================================================================
# Step dataclasses
# ============================================================================


[docs] @dataclass class AddNode: """Add a node to the scene via state.add_node().""" type_name: str parent_path: str = "" name: str = ""
[docs] @dataclass class RenameNode: """Rename a node via state.rename_node().""" path: str new_name: str
[docs] @dataclass class SelectNode: """Select a node via state.selection.select().""" path: str
[docs] @dataclass class SetProperty: """Set a Property-descriptor value via state.set_node_property().""" path: str prop: str value: object
[docs] @dataclass class AssertProperty: """Assert a node property equals expected, with actual-value diagnostics.""" path: str prop: str expected: object tolerance: float = 0.5 message: str = ""
[docs] @dataclass class SetScript: """Set the code viewport text via state.set_script_text().""" text: str
[docs] @dataclass class PlayScene: """Enter play mode via state.play_scene()."""
[docs] @dataclass class StopScene: """Exit play mode via state.stop_scene()."""
[docs] @dataclass class SwitchViewport: """Switch viewport mode (\"3d\", \"2d\", or \"code\").""" mode: str
[docs] @dataclass class AssertTree: """Verify scene tree structure. *expected* maps path -> type_name or path -> (type_name, child_count). """ expected: dict message: str = ""
[docs] @dataclass class PlaceNode: """Add a node at a specific position (mouse-placement style).""" type_name: str x: float = 0.0 y: float = 0.0 parent_path: str = "" name: str = ""
[docs] @dataclass class ConfigureNode: """Bulk-set attributes on a node (for non-Property attrs like colours).""" path: str props: dict = field(default_factory=dict)
[docs] @dataclass class AssertConfig: """Verify multiple properties on a node match expected values.""" path: str props: dict = field(default_factory=dict) message: str = ""
# ============================================================================ # Handler helpers # ============================================================================ def _get_editor(runner: DemoRunner): """Find the EditorShell among the runner's siblings.""" from simvx.editor.app import EditorShell parent = runner.parent if not parent: return None for c in parent.children: if isinstance(c, EditorShell): return c return None def _get_state(runner: DemoRunner): editor = _get_editor(runner) return editor.state if editor else None def _resolve_node(runner: DemoRunner, path: str) -> Node | None: state = _get_state(runner) return state.find_node(path) if state else None # ============================================================================ # Handlers # ============================================================================ def _handle_add_node(runner: DemoRunner, step: AddNode, dt: float): state = _get_state(runner) if not state: runner._advance() return cls = Node._registry.get(step.type_name) if not cls: raise ValueError(f"Unknown node type: {step.type_name}") node = cls(name=step.name or step.type_name) parent = state.find_node(step.parent_path) state.add_node(node, parent) # Rebuild the scene tree panel if available editor = _get_editor(runner) if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"): editor.scene_tree_panel._rebuild_tree() runner._advance() def _handle_rename_node(runner: DemoRunner, step: RenameNode, dt: float): state = _get_state(runner) if not state: runner._advance() return node = state.find_node(step.path) if not node: raise ValueError(f"Node not found: {step.path}") state.rename_node(node, step.new_name) editor = _get_editor(runner) if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"): editor.scene_tree_panel._rebuild_tree() runner._advance() def _handle_select_node(runner: DemoRunner, step: SelectNode, dt: float): state = _get_state(runner) if not state: runner._advance() return node = state.find_node(step.path) if node: state.selection.select(node) editor = _get_editor(runner) if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"): editor.scene_tree_panel._rebuild_tree() runner._advance() def _handle_set_property(runner: DemoRunner, step: SetProperty, dt: float): state = _get_state(runner) if not state: runner._advance() return node = state.find_node(step.path) if not node: raise ValueError(f"Node not found: {step.path}") state.set_node_property(node, step.prop, step.value) runner._advance() def _handle_assert_property(runner: DemoRunner, step: AssertProperty, dt: float): state = _get_state(runner) node = _resolve_node(runner, step.path) if state else None if not node: msg = step.message or f"AssertProperty: node not found: {step.path}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return actual = getattr(node, step.prop, None) ok = False if isinstance(step.expected, float) and actual is not None: ok = abs(actual - step.expected) < step.tolerance else: ok = actual == step.expected if not ok: msg = step.message or f"{step.path}.{step.prop}: expected {step.expected!r}, got {actual!r}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() def _handle_set_script(runner: DemoRunner, step: SetScript, dt: float): state = _get_state(runner) if state: state.set_script_text(step.text) runner._advance() def _handle_play_scene(runner: DemoRunner, step: PlayScene, dt: float): state = _get_state(runner) if state: state.play_scene() runner._advance() def _handle_stop_scene(runner: DemoRunner, step: StopScene, dt: float): state = _get_state(runner) if state: state.stop_scene() runner._advance() def _handle_switch_viewport(runner: DemoRunner, step: SwitchViewport, dt: float): editor = _get_editor(runner) if editor: editor._set_viewport_mode(step.mode) runner._advance() def _handle_assert_tree(runner: DemoRunner, step: AssertTree, dt: float): state = _get_state(runner) root = state.find_node("") if state else None if not root: msg = step.message or "AssertTree: no scene root" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return for path, spec in step.expected.items(): try: n = root[path] if path else root except (KeyError, ValueError): msg = step.message or f"AssertTree: node not found: {path}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) from None runner._advance() return if isinstance(spec, tuple): type_name, child_count = spec actual_type = type(n).__name__ actual_count = len(list(n.children)) if actual_type != type_name or actual_count != child_count: msg = step.message or ( f"AssertTree: {path or 'root'}: expected ({type_name}, {child_count}), " f"got ({actual_type}, {actual_count})" ) runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return else: actual_type = type(n).__name__ if actual_type != spec: msg = step.message or f"AssertTree: {path or 'root'}: expected {spec}, got {actual_type}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return runner._advance() def _handle_place_node(runner: DemoRunner, step: PlaceNode, dt: float): state = _get_state(runner) if not state: runner._advance() return cls = Node._registry.get(step.type_name) if not cls: raise ValueError(f"Unknown node type: {step.type_name}") parent = state.find_node(step.parent_path) # Use place_node_at for position-aware placement state.pending_place_type = cls node = state.place_node_at(step.x, step.y, parent) if node and step.name: node.name = step.name # Rebuild the scene tree panel if available editor = _get_editor(runner) if editor and editor.scene_tree_panel and hasattr(editor.scene_tree_panel, "_rebuild_tree"): editor.scene_tree_panel._rebuild_tree() runner._advance() def _handle_configure_node(runner: DemoRunner, step: ConfigureNode, dt: float): node = _resolve_node(runner, step.path) if not node: raise ValueError(f"ConfigureNode: node not found: {step.path}") for k, v in step.props.items(): setattr(node, k, v) runner._advance() def _handle_assert_config(runner: DemoRunner, step: AssertConfig, dt: float): node = _resolve_node(runner, step.path) if not node: msg = step.message or f"AssertConfig: node not found: {step.path}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return for k, expected in step.props.items(): actual = getattr(node, k, None) ok = True if actual is None: ok = False elif isinstance(expected, float): ok = abs(actual - expected) < 0.5 elif isinstance(expected, tuple): ok = len(actual) == len(expected) and all(abs(a - e) < 0.02 for a, e in zip(actual, expected, strict=True)) else: ok = actual == expected if not ok: msg = step.message or f"AssertConfig: {step.path}.{k}: expected {expected!r}, got {actual!r}" runner._failures.append(msg) runner._failed = True if runner._test_mode: raise AssertionError(msg) runner._advance() return runner._advance() # ============================================================================ # Mouse-driven step types — interact through the editor UI with visible cursor # ============================================================================
[docs] @dataclass class ClickAddButton: """Click the '+' button in the Scene Tree to open the Add Node dialog.""" _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class DialogSelect: """Type filter text and click a type row in the open Add Node dialog.""" type_name: str _phase: int = field(default=0, init=False, repr=False) _char_idx: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class ClickTreeItem: """Click a node in the scene tree by path to select it.""" path: str _phase: int = field(default=0, init=False, repr=False) _retried: bool = field(default=False, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class InspectorRename: """Click the inspector name field and type a new name.""" new_name: str _phase: int = field(default=0, init=False, repr=False) _char_idx: int = field(default=0, init=False, repr=False) _cleared: int = field(default=0, init=False, repr=False) _clear_count: int = field(default=0, init=False, repr=False) _edit_ref: object = field(default=None, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class InspectorEdit: """Edit a property via the inspector widget (SpinBox, TextEdit, or Slider).""" prop: str value: object component: int = -1 # For VectorRow: 0=X, 1=Y, 2=Z; -1=direct widget _phase: int = field(default=0, init=False, repr=False) _char_idx: int = field(default=0, init=False, repr=False) _cleared: int = field(default=0, init=False, repr=False) _clear_count: int = field(default=0, init=False, repr=False) _widget_ref: object = field(default=None, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class InspectorSetColour: """Set a colour via the inspector ColourPicker hex input (click hex field, type value, enter).""" prop: str colour: tuple _phase: int = field(default=0, init=False, repr=False) _char_idx: int = field(default=0, init=False, repr=False) _cleared: int = field(default=0, init=False, repr=False) _clear_count: int = field(default=0, init=False, repr=False) _widget_ref: object = field(default=None, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class ClickToolbar: """Click a named toolbar button ('Play', 'Stop', 'Pause', '3D', '2D', 'Code').""" name: str _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class ClickInspectorButton: """Click a button by text label in the inspector panel.""" label: str _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class PasteInEditor: """Paste content into the active code editor via clipboard + Ctrl+A/Ctrl+V.""" content: str _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class ClickViewport3D: """Click at a 3D world position in the viewport during play mode. Projects world_pos to screen coordinates using the playing scene's camera, moves the cursor there, and calls input_cast() for 3D picking. """ world_pos: tuple[float, float, float] _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
# ============================================================================ # Mouse-driven cursor helpers # ============================================================================ def _smoothstep(t: float) -> float: t = max(0.0, min(1.0, t)) return t * t * (3.0 - 2.0 * t) def _init_click(step, runner: DemoRunner, target: tuple): """Prepare a step for cursor-move-and-click at *target*.""" step._mc_target = target step._mc_origin = runner._cursor_pos step._mc_phase = 0 step._mc_t = 0.0 def _drive_click(runner: DemoRunner, step, dt: float, move_dur: float = 0.25) -> bool: """Drive cursor move -> press -> release. Returns True when done.""" if step._mc_phase == 0: # move step._mc_t += dt t = min(step._mc_t / max(move_dur, 0.001), 1.0) t = _smoothstep(t) ox, oy = step._mc_origin tx, ty = step._mc_target runner._cursor_pos = (ox + (tx - ox) * t, oy + (ty - oy) * t) runner._sim.move_mouse(*runner._cursor_pos) if t >= 1.0: runner._cursor_pos = step._mc_target step._mc_phase = 1 step._mc_t = 0.0 elif step._mc_phase == 1: # press runner._sim.press_mouse(1, step._mc_target) runner._dispatch_ui_mouse(1, True) step._mc_phase = 2 step._mc_t = 0.0 elif step._mc_phase == 2: # release step._mc_t += dt if step._mc_t >= 0.05: runner._sim.release_mouse(1) runner._dispatch_ui_mouse(1, False) return True return False def _send_key(runner: DemoRunner, key: str): """Press and release a key through the UI input system.""" if runner._tree: runner._tree.ui_input(key=key, pressed=True) runner._tree.ui_input(key=key, pressed=False) def _drive_backspace_clear(runner: DemoRunner, step, dt: float) -> bool: """Clear focused widget text via backspace. Returns True when done.""" if step._cleared >= step._clear_count: return True step._mc_t += dt while step._mc_t >= 0.01 and step._cleared < step._clear_count: _send_key(runner, "backspace") step._cleared += 1 step._mc_t -= 0.01 return step._cleared >= step._clear_count def _drive_type_chars(runner: DemoRunner, step, dt: float, text: str) -> bool: """Type *text* char by char. Returns True when done.""" step._mc_t += dt while step._mc_t >= 0.02 and step._char_idx < len(text): ch = text[step._char_idx] if runner._tree: runner._tree.ui_input(char=ch) step._char_idx += 1 step._mc_t -= 0.02 return step._char_idx >= len(text) def _walk_children(node): """Recursively yield all descendants of a UI node.""" for child in node.children: yield child yield from _walk_children(child) def _find_tree_item(root_item, target_node): """Walk TreeItem hierarchy to find the item whose data matches *target_node*.""" if root_item.data is target_node: return root_item for child in root_item.children: found = _find_tree_item(child, target_node) if found: return found return None def _colour_to_hex(colour: tuple) -> str: """Convert an (r, g, b[, a]) float tuple to a #RRGGBB hex string.""" r = max(0, min(255, int(round(colour[0] * 255)))) g = max(0, min(255, int(round(colour[1] * 255)))) b = max(0, min(255, int(round(colour[2] * 255)))) return f"#{r:02X}{g:02X}{b:02X}" def _find_prop_widget(inspector, prop: str, component: int = -1): """Find the inspector widget for *prop*, optionally a vector component. Falls back to ``mat_`` prefix for material properties (colour, roughness, etc.). """ widget = inspector._property_widgets.get(prop) if widget is None: widget = inspector._property_widgets.get(f"mat_{prop}") if widget is None: return None if component >= 0 and hasattr(widget, "_spinboxes"): sbs = widget._spinboxes return sbs[component] if component < len(sbs) else None return widget # ============================================================================ # Mouse-driven handlers # ============================================================================ def _handle_click_add_button(runner: DemoRunner, step: ClickAddButton, dt: float): runner._action_desc = "Click: + (Add Node)" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.scene_tree_panel: runner._advance() return btn = editor.scene_tree_panel._add_btn gx, gy, gw, gh = btn.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): runner._advance() def _handle_dialog_select(runner: DemoRunner, step: DialogSelect, dt: float): runner._action_desc = f"Select: {step.type_name}" editor = _get_editor(runner) if step._phase == 0: # Type filter chars into the dialog's focused filter field step._mc_t = getattr(step, "_mc_t", 0.0) if _drive_type_chars(runner, step, dt, step.type_name): step._phase = 1 step._mc_t = 0.0 elif step._phase == 1: # Wait for filter to apply step._mc_t += dt if step._mc_t >= 0.05: if not editor or not editor.scene_tree_panel: runner._advance() return dialog = editor.scene_tree_panel._add_dialog if not dialog.visible: runner._advance() return gx, gy, _, _ = dialog.get_global_rect() row_y = dialog.type_row_y(step.type_name) if row_y is not None: _init_click(step, runner, (gx + dialog.DIALOG_WIDTH / 2, row_y + dialog.ROW_HEIGHT / 2)) step._phase = 2 return runner._advance() # type not found in filtered list elif step._phase == 2: if _drive_click(runner, step, dt): runner._advance() def _handle_click_tree_item(runner: DemoRunner, step: ClickTreeItem, dt: float): runner._action_desc = f"Select: {step.path.rsplit('/', 1)[-1] or 'root'}" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.scene_tree_panel: runner._advance() return target_node = editor.state.find_node(step.path) if not target_node: runner._advance() return tree_view = editor.scene_tree_panel._tree_view for item, rx, ry, depth in tree_view._row_map: if item.data is target_node: click_x = rx + depth * tree_view.indent + tree_view.row_height + 20 click_y = ry + tree_view.row_height / 2 _init_click(step, runner, (click_x, click_y)) step._phase = 1 return # Not visible in _row_map — find the TreeItem and expand ancestors tree_item = _find_tree_item(tree_view.root, target_node) if tree_view.root else None if not tree_item: raise ValueError(f"ClickTreeItem: node not found in tree: {step.path}") parent = tree_item.parent while parent: parent.expanded = True parent = parent.parent editor.scene_tree_panel._rebuild_tree() # If _row_map is empty (headless — draw never runs), select through TreeView if not tree_view._row_map: tree_view.selected = tree_item tree_view.item_selected.emit(tree_item) runner._advance() return # Otherwise retry once on next frame (draw will populate _row_map) if step._retried: raise ValueError(f"ClickTreeItem: node not visible after expanding ancestors: {step.path}") step._retried = True elif step._phase == 1: if _drive_click(runner, step, dt): runner._advance() def _handle_inspector_rename(runner: DemoRunner, step: InspectorRename, dt: float): runner._action_desc = f"Rename: {step.new_name}" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.inspector_panel or not editor.inspector_panel._name_edit: runner._advance() return edit = editor.inspector_panel._name_edit gx, gy, gw, gh = edit.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._edit_ref = edit step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): _send_key(runner, "end") step._clear_count = len(step._edit_ref.text) step._cleared = 0 step._mc_t = 0.0 step._phase = 2 elif step._phase == 2: if _drive_backspace_clear(runner, step, dt): step._char_idx = 0 step._mc_t = 0.0 step._phase = 3 elif step._phase == 3: if _drive_type_chars(runner, step, dt, step.new_name): step._phase = 4 elif step._phase == 4: _send_key(runner, "enter") if editor and editor.scene_tree_panel: editor.scene_tree_panel._rebuild_tree() runner._advance() def _handle_inspector_edit(runner: DemoRunner, step: InspectorEdit, dt: float): runner._action_desc = f"Set: {step.prop} = {step.value}" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.inspector_panel: runner._advance() return widget = _find_prop_widget(editor.inspector_panel, step.prop, step.component) if not widget: runner._advance() return step._widget_ref = widget gx, gy, gw, gh = widget.get_global_rect() # Determine click target based on widget type from simvx.core.ui.advanced import SpinBox as _SpinBox from simvx.core.ui.widgets import Slider as _Slider if isinstance(widget, _Slider): rng = widget.max_value - widget.min_value ratio = max(0.0, min(1.0, (float(step.value) - widget.min_value) / rng)) if rng > 0 else 0 target = (gx + ratio * gw, gy + gh / 2) elif isinstance(widget, _SpinBox): bw = widget._button_width() target = (gx + (gw - bw) / 2, gy + gh / 2) else: target = (gx + gw / 2, gy + gh / 2) _init_click(step, runner, target) step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): from simvx.core.ui.advanced import SpinBox as _SpinBox from simvx.core.ui.widgets import Slider as _Slider from simvx.core.ui.widgets import TextEdit as _TextEdit widget = step._widget_ref if isinstance(widget, _Slider): runner._advance() elif isinstance(widget, _SpinBox): step._char_idx = 0 step._mc_t = 0.0 step._phase = 3 # SpinBox starts with empty input — just type elif isinstance(widget, _TextEdit): _send_key(runner, "end") step._clear_count = len(widget.text) step._cleared = 0 step._mc_t = 0.0 step._phase = 2 else: runner._advance() elif step._phase == 2: # clear TextEdit via backspace if _drive_backspace_clear(runner, step, dt): val_str = str(step.value) if not val_str: step._phase = 4 else: step._char_idx = 0 step._mc_t = 0.0 step._phase = 3 elif step._phase == 3: # type value if _drive_type_chars(runner, step, dt, str(step.value)): step._phase = 4 step._mc_t = 0.0 elif step._phase == 4: # enter to submit _send_key(runner, "enter") runner._advance() def _handle_inspector_set_colour(runner: DemoRunner, step: InspectorSetColour, dt: float): runner._action_desc = f"Colour: {step.prop}" editor = _get_editor(runner) if step._phase == 0: # Find the ColourPicker widget in inspector if not editor or not editor.inspector_panel: raise ValueError(f"InspectorSetColour: no inspector panel for {step.prop}") widget = editor.inspector_panel._property_widgets.get(step.prop) if widget is None: widget = editor.inspector_panel._property_widgets.get(f"mat_{step.prop}") if not widget: raise ValueError(f"InspectorSetColour: property widget not found: {step.prop}") from simvx.core.ui.colour_picker import ColourPicker as _CP if not isinstance(widget, _CP): raise ValueError(f"InspectorSetColour: widget for {step.prop} is not a ColourPicker") step._widget_ref = widget # Click the hex input area hx, hy, hw, hh = widget._hex_rect() _init_click(step, runner, (hx + hw / 2, hy + hh / 2)) step._phase = 1 elif step._phase == 1: # Drive click on hex rect → activates hex editing mode if _drive_click(runner, step, dt): step._clear_count = len(step._widget_ref._hex_text) step._cleared = 0 step._mc_t = 0.0 step._phase = 2 elif step._phase == 2: # Clear existing hex text via backspace if _drive_backspace_clear(runner, step, dt): step._char_idx = 0 step._mc_t = 0.0 step._phase = 3 elif step._phase == 3: # Type hex colour value char by char if _drive_type_chars(runner, step, dt, _colour_to_hex(step.colour)): step._phase = 4 elif step._phase == 4: # Press enter to commit the hex value _send_key(runner, "enter") runner._advance() def _handle_click_toolbar(runner: DemoRunner, step: ClickToolbar, dt: float): runner._action_desc = f"Click: {step.name}" editor = _get_editor(runner) if step._phase == 0: if not editor: runner._advance() return btn_map = { "Play": getattr(editor, "_play_btn", None), "Stop": getattr(editor, "_stop_btn", None), "Pause": getattr(editor, "_pause_btn", None), "3D": getattr(editor, "_3d_btn", None), "2D": getattr(editor, "_2d_btn", None), "Script": getattr(editor, "_script_btn", None), } btn = btn_map.get(step.name) if not btn: runner._advance() return gx, gy, gw, gh = btn.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): runner._advance() def _handle_click_inspector_button(runner: DemoRunner, step: ClickInspectorButton, dt: float): runner._action_desc = f"Click: {step.label}" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.inspector_panel: raise ValueError("ClickInspectorButton: no inspector panel") from simvx.core import Button as _Button btn = None for child in _walk_children(editor.inspector_panel): if isinstance(child, _Button) and child.text == step.label: btn = child break if not btn: raise ValueError(f"ClickInspectorButton: button '{step.label}' not found in inspector") gx, gy, gw, gh = btn.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): runner._advance() def _handle_paste_in_editor(runner: DemoRunner, step: PasteInEditor, dt: float): runner._action_desc = "Paste: script" editor = _get_editor(runner) if step._phase == 0: # Set up in-memory clipboard backend if none is active from simvx.core.ui import clipboard if clipboard._copy_fn is None: _mem: dict[str, str] = {} clipboard.set_backend( lambda t: _mem.__setitem__("text", t), lambda: _mem.get("text", ""), ) clipboard.copy(step.content) # Find the code editor and click it to give focus if editor and editor._script_tabs: ed = editor._script_tabs.get_current_editor() if ed: gx, gy, gw, gh = ed.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._phase = 1 return raise ValueError("PasteInEditor: no active code editor tab") elif step._phase == 1: # Click to focus the code editor if _drive_click(runner, step, dt): step._phase = 2 step._mc_t = 0.0 elif step._phase == 2: # Select all (ctrl+a) then paste (ctrl+v) _send_key(runner, "ctrl+a") step._phase = 3 step._mc_t = 0.0 elif step._phase == 3: step._mc_t += dt if step._mc_t >= 0.02: _send_key(runner, "ctrl+v") step._phase = 4 step._mc_t = 0.0 elif step._phase == 4: # Save the pasted content to disk, ensuring text was set step._mc_t += dt if step._mc_t >= 0.02: if editor and editor._script_tabs: ed = editor._script_tabs.get_current_editor() # Fallback: if ctrl+v didn't work (headless), set text directly if ed and step.content not in ed.text: ed.text = step.content editor._script_tabs.save_current_script() runner._advance() # ============================================================================ # 3D world-to-screen projection # ============================================================================ def _world_to_screen(world_pos, camera, screen_size): """Project a 3D world position to 2D screen coordinates. Args: world_pos: (x, y, z) world position camera: Camera3D node (provides view_matrix, projection_matrix) screen_size: (width, height) in pixels Returns: (screen_x, screen_y) tuple, or None if behind camera """ import numpy as np sw, sh = float(screen_size[0]), float(screen_size[1]) aspect = sw / sh if sh > 0 else 1.0 view = camera.view_matrix proj = camera.projection_matrix(aspect) vp = proj @ view p = np.array([float(world_pos[0]), float(world_pos[1]), float(world_pos[2]), 1.0], dtype=np.float32) clip = vp @ p if clip[3] <= 0: return None # behind camera ndc_x = clip[0] / clip[3] ndc_y = clip[1] / clip[3] # Vulkan Y-flip: proj[1,1] < 0 if proj[1, 1] < 0: ndc_y = -ndc_y screen_x = (ndc_x + 1.0) * 0.5 * sw screen_y = (1.0 - ndc_y) * 0.5 * sh return (screen_x, screen_y) def _handle_click_viewport3d(runner: DemoRunner, step: ClickViewport3D, dt: float): runner._action_desc = f"Click3D: {step.world_pos}" editor = _get_editor(runner) if step._phase == 0: if not editor or not editor.state._playing_root: raise ValueError("ClickViewport3D: not in play mode") pr = editor.state._playing_root tree = pr.tree if hasattr(pr, "tree") and pr.tree else pr._tree if hasattr(pr, "_tree") else None # Find Camera3D in the playing scene from simvx.core import Camera3D cameras = pr.find_all(Camera3D) if hasattr(pr, "find_all") else [] if not cameras and tree: cameras = tree.root.find_all(Camera3D) if tree.root else [] if not cameras: raise ValueError("ClickViewport3D: no Camera3D in playing scene") camera = cameras[0] # Get screen size from the tree if tree: screen_size = tree.screen_size else: screen_size = (1600, 900) # Project world position to screen coordinates within the scene scene_screen = _world_to_screen(step.world_pos, camera, screen_size) if scene_screen is None: raise ValueError(f"ClickViewport3D: world pos {step.world_pos} is behind camera") # Find the viewport container rect to offset screen coords into editor space vc = editor.state._viewport_container if vc: vx, vy, vw, vh = vc.get_global_rect() # Map scene screen coords (relative to viewport) into editor screen coords target = (vx + scene_screen[0] * vw / screen_size[0], vy + scene_screen[1] * vh / screen_size[1]) else: target = scene_screen _init_click(step, runner, target) step._phase = 1 elif step._phase == 1: if _drive_click(runner, step, dt): # Also fire input_cast on the playing scene's tree for CPU picking if editor and editor.state._playing_root: pr = editor.state._playing_root tree = pr.tree if hasattr(pr, "tree") and pr.tree else pr._tree if hasattr(pr, "_tree") else None if tree and hasattr(tree, "input_cast"): from simvx.core import Camera3D cameras = pr.find_all(Camera3D) if hasattr(pr, "find_all") else [] if not cameras and tree.root: cameras = tree.root.find_all(Camera3D) if cameras: screen_size = tree.screen_size scene_screen = _world_to_screen(step.world_pos, cameras[0], screen_size) if scene_screen: tree.input_cast(scene_screen, button=1) runner._advance() # ============================================================================ # Menu and dialog step types # ============================================================================
[docs] @dataclass class ClickMenu: """Click a menu label on the MenuBar, then click an item in the dropdown.""" menu_name: str item_text: str _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
[docs] @dataclass class NewSceneSelect: """Click a row in the NewSceneDialog to choose a root type.""" root_type: str # "3D Scene", "2D Scene", "UI Scene", "Empty" _phase: int = field(default=0, init=False, repr=False) _mc_target: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_origin: tuple = field(default=(0.0, 0.0), init=False, repr=False) _mc_phase: int = field(default=0, init=False, repr=False) _mc_t: float = field(default=0.0, init=False, repr=False)
def _handle_click_menu(runner: DemoRunner, step: ClickMenu, dt: float): runner._action_desc = f"Menu: {step.menu_name} > {step.item_text}" editor = _get_editor(runner) if step._phase == 0: # Find menu label rect on MenuBar if not editor or not editor._menu_bar: runner._advance() return bar = editor._menu_bar rects = bar._menu_rects() for i, (name, _popup) in enumerate(bar.menus): if name == step.menu_name: lx, ly, lw, lh = rects[i] _init_click(step, runner, (lx + lw / 2, ly + lh / 2)) step._phase = 1 return runner._advance() # menu not found elif step._phase == 1: # Click to open menu if _drive_click(runner, step, dt): step._phase = 2 step._mc_t = 0.0 elif step._phase == 2: # Find item in the open popup step._mc_t += dt if step._mc_t < 0.05: return # brief wait for popup editor = _get_editor(runner) if not editor or not editor._menu_bar: runner._advance() return bar = editor._menu_bar if bar._open_index < 0: runner._advance() return _name, popup = bar.menus[bar._open_index] gx, gy, _gw, _gh = popup.get_global_rect() for i, item in enumerate(popup.items): if item.text == step.item_text: row_y = gy + i * 28 + 14 # approximate row center _init_click(step, runner, (gx + popup.size.x / 2, row_y)) step._phase = 3 return # Item not found — close menu and advance bar._close_all() runner._advance() elif step._phase == 3: # Click the menu item if _drive_click(runner, step, dt): runner._advance() def _handle_new_scene_select(runner: DemoRunner, step: NewSceneSelect, dt: float): runner._action_desc = f"NewScene: {step.root_type}" editor = _get_editor(runner) if step._phase == 0: # Wait for NewSceneDialog to appear if not editor: runner._advance() return dialog = getattr(editor, "_new_scene_dialog", None) if not dialog or not dialog.visible: # In headless mode, the dialog may not exist yet — call new_scene directly state = editor.state type_map = { "3D Scene (Default)": (_resolve_root_type("Node3D"), True), "3D Scene": (_resolve_root_type("Node3D"), False), "2D Scene": (_resolve_root_type("Node2D"), False), "UI Scene": (_resolve_root_type("Control"), False), "Empty": (Node, False), } rt, pop = type_map.get(step.root_type, (Node, False)) state.new_scene(root_type=rt, populate=pop) runner._advance() return # Find the matching row button inner = dialog._dialog_panel if not inner: runner._advance() return from simvx.core import Button as _Button for child in inner.children: if isinstance(child, _Button) and child.text == step.root_type: gx, gy, gw, gh = child.get_global_rect() _init_click(step, runner, (gx + gw / 2, gy + gh / 2)) step._phase = 1 return runner._advance() elif step._phase == 1: if _drive_click(runner, step, dt): runner._advance() def _resolve_root_type(name: str) -> type: """Resolve a node type name for NewSceneSelect.""" return Node._registry.get(name, Node) # ============================================================================ # Registration # ============================================================================ _HANDLERS = { AddNode: _handle_add_node, PlaceNode: _handle_place_node, RenameNode: _handle_rename_node, SelectNode: _handle_select_node, SetProperty: _handle_set_property, AssertProperty: _handle_assert_property, SetScript: _handle_set_script, PlayScene: _handle_play_scene, StopScene: _handle_stop_scene, SwitchViewport: _handle_switch_viewport, AssertTree: _handle_assert_tree, ConfigureNode: _handle_configure_node, AssertConfig: _handle_assert_config, # Mouse-driven steps ClickAddButton: _handle_click_add_button, DialogSelect: _handle_dialog_select, ClickTreeItem: _handle_click_tree_item, InspectorRename: _handle_inspector_rename, InspectorEdit: _handle_inspector_edit, InspectorSetColour: _handle_inspector_set_colour, ClickToolbar: _handle_click_toolbar, ClickInspectorButton: _handle_click_inspector_button, PasteInEditor: _handle_paste_in_editor, ClickViewport3D: _handle_click_viewport3d, ClickMenu: _handle_click_menu, NewSceneSelect: _handle_new_scene_select, }
[docs] def register_editor_steps(): """Register all editor step handlers with DemoRunner.""" for step_type, handler in _HANDLERS.items(): DemoRunner.register_step_handler(step_type, handler)
__all__ = [ "AddNode", "PlaceNode", "RenameNode", "SelectNode", "SetProperty", "AssertProperty", "SetScript", "PlayScene", "StopScene", "SwitchViewport", "AssertTree", "ConfigureNode", "AssertConfig", "ClickAddButton", "DialogSelect", "ClickTreeItem", "InspectorRename", "InspectorEdit", "InspectorSetColour", "ClickToolbar", "ClickInspectorButton", "PasteInEditor", "ClickViewport3D", "ClickMenu", "NewSceneSelect", "register_editor_steps", ]