"""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 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 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 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 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",
]