Source code for simvx.editor.testing.demo_steps._helpers

"""Auto-split from the former flat demo_steps.py."""

import logging

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

log = logging.getLogger(__name__)

def _get_editor(runner: DemoRunner):
    """Find the Root among the runner's siblings.

    Auto-dismisses the first-run TourGuide if active, since it covers
    the entire screen and blocks all simulated mouse input.
    """
    from simvx.editor.root import Root

    parent = runner.parent
    if not parent:
        return None
    for c in parent.children:
        if isinstance(c, Root):
            tour = getattr(c, "_tour", None)
            if tour and getattr(tour, "is_active", False):
                tour.stop()
            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

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
        # Sim now drives Input state, ``@on_input`` decorators, and UI widget
        # dispatch in one call, no separate ``_dispatch_ui_mouse`` needed.
        runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
        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(MouseButton.LEFT)
            return True
    return False

def _init_drag(step, runner: DemoRunner, start: tuple, end: tuple):
    """Prepare a step for cursor move -> press -> drag -> release.

    *start* is where the press lands, *end* where the release lands.
    """
    step._mc_target = start
    step._mc_end = end
    step._mc_origin = runner._cursor_pos
    step._mc_phase = 0
    step._mc_t = 0.0

def _drive_drag(runner: DemoRunner, step, dt: float, move_dur: float = 0.2, drag_dur: float = 0.2) -> bool:
    """Drive cursor move -> press -> drag-to-end -> release.  Returns True when done.

    Thin wrapper over ``InputSimulator.move_mouse``/``press_mouse``/
    ``release_mouse``, the same primitive UITestHarness.drag() uses, routed
    through the runner's bound tree so ``Control._on_gui_input`` fires for
    every motion sample (panels read ``event.position`` for drag deltas).
    """
    if step._mc_phase == 0:  # move cursor to the press point
        step._mc_t += dt
        t = _smoothstep(min(step._mc_t / max(move_dur, 0.001), 1.0))
        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 at the start point
        runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
        step._mc_phase = 2
        step._mc_t = 0.0
    elif step._mc_phase == 2:  # drag toward the end point, sampling motion
        step._mc_t += dt
        t = _smoothstep(min(step._mc_t / max(drag_dur, 0.001), 1.0))
        sx, sy = step._mc_target
        ex, ey = step._mc_end
        runner._cursor_pos = (sx + (ex - sx) * t, sy + (ey - sy) * t)
        runner._sim.move_mouse(*runner._cursor_pos)
        if t >= 1.0:
            runner._cursor_pos = step._mc_end
            step._mc_phase = 3
            step._mc_t = 0.0
    elif step._mc_phase == 3:  # release at the end point
        step._mc_t += dt
        if step._mc_t >= 0.05:
            runner._sim.release_mouse(MouseButton.LEFT)
            return True
    return False

def _drive_double_click(runner: DemoRunner, step, dt: float, gap: float = 0.08) -> bool:
    """Drive cursor move -> click -> click (a double-click).  Returns True when done.

    Wraps two ``InputSimulator`` press/release pairs at the same point, the
    same primitive UITestHarness.double_click() uses, separated by a short
    *gap* so the target widget's own double-click timer registers both as one
    double-click.
    """
    if step._mc_phase == 0:  # move
        step._mc_t += dt
        t = _smoothstep(min(step._mc_t / 0.2, 1.0))
        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:  # first click (press + release)
        runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
        runner._sim.release_mouse(MouseButton.LEFT)
        step._mc_phase = 2
        step._mc_t = 0.0
    elif step._mc_phase == 2:  # short gap so the widget's dbl-click timer keeps both
        step._mc_t += dt
        if step._mc_t >= gap:
            step._mc_phase = 3
            step._mc_t = 0.0
    elif step._mc_phase == 3:  # second click (press + release)
        runner._sim.press_mouse(MouseButton.LEFT, step._mc_target)
        runner._sim.release_mouse(MouseButton.LEFT)
        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
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 _resolve_root_type(name: str) -> type:
    """Resolve a node type name for NewSceneSelect."""
    return Node._registry.get(name, Node)