Source code for simvx.editor.testing.demo_steps.handlers.viewport

"""Editor viewport and canvas interaction handlers."""

import logging

from simvx.core.scripted_demo import DemoRunner

from .._helpers import (
    _drive_click,
    _drive_double_click,
    _drive_drag,
    _get_editor,
    _init_click,
    _init_drag,
    _send_key,
    _world_to_screen,
)
from ..steps import (
    ClickCodeCaret,
    ClickGameNode,
    ClickViewport3D,
    DoubleClickFile,
    DragGizmoAxis,
    PasteInEditor,
)

log = logging.getLogger(__name__)


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.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.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 _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, MouseButton

                    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=MouseButton.LEFT)
            runner._advance()


# ============================================================================
# Game-tree node resolver: clicks a path inside the running game's tree
# ============================================================================
def _handle_click_game_node(runner: DemoRunner, step: ClickGameNode, dt: float):
    runner._action_desc = f"ClickGameNode: {step.path}"
    editor = _get_editor(runner)
    if step._phase == 0:
        if editor is None:
            raise ValueError("ClickGameNode: no editor")
        play_mode = editor.state.play_mode
        if play_mode is None or play_mode.game_tree is None or play_mode.game_tree.root is None:
            raise ValueError("ClickGameNode: not in play mode")

        game_root = play_mode.game_tree.root
        try:
            node = game_root.get_node(step.path)
        except (KeyError, ValueError) as exc:
            raise ValueError(f"ClickGameNode: path {step.path!r} not resolvable") from exc

        # Game-space rect (UI Control returns screen-space (x, y, w, h))
        if not hasattr(node, "get_global_rect"):
            raise ValueError(f"ClickGameNode: node at {step.path!r} has no get_global_rect()")
        gx, gy, gw, gh = node.get_global_rect()
        game_cx = gx + gw / 2.0
        game_cy = gy + gh / 2.0

        # Inverse of the forward_input_to_game scale: game-space -> viewport-relative
        game_w, game_h = play_mode.game_tree.screen_size
        vc = editor.state._viewport_container
        if vc is None:
            raise ValueError("ClickGameNode: no viewport container")
        vx, vy, vw, vh = vc.get_global_rect()
        if game_w <= 0 or game_h <= 0:
            raise ValueError(f"ClickGameNode: invalid game screen size {(game_w, game_h)!r}")
        target = (
            vx + game_cx * (vw / game_w),
            vy + game_cy * (vh / game_h),
        )

        _init_click(step, runner, target)
        step._phase = 1
    elif step._phase == 1:
        if _drive_click(runner, step, dt):
            runner._advance()


# ============================================================================
# Viewport gizmo drag: drives the 2D Scene view's translate gizmo via input
# ============================================================================
def _scene2d_view(editor):
    """Return the editor's Scene2DView panel if present, else None."""
    from simvx.editor.panels.scene2d_view import Scene2DView

    panel = getattr(editor, "_viewport_2d", None)
    return panel if isinstance(panel, Scene2DView) else None


def _handle_drag_gizmo_axis(runner: DemoRunner, step: DragGizmoAxis, dt: float):
    runner._action_desc = f"DragGizmo: {step.path} {step.axis}"
    editor = _get_editor(runner)
    if step._phase == 0:
        if editor is None:
            raise ValueError("DragGizmoAxis: no editor")
        from simvx.core import GizmoMode, Node2D

        # Select the node and switch the editor to the 2D view + translate gizmo.
        node = editor.state.find_node(step.path)
        if not isinstance(node, Node2D):
            raise ValueError(f"DragGizmoAxis: node at {step.path!r} is not a Node2D")
        editor.state.selection.select(node)
        editor._on_mode("2d")  # canonical switch: sets mode + emits viewport_mode_changed
        editor.state.gizmo.mode = GizmoMode.TRANSLATE

        view = _scene2d_view(editor)
        if view is None:
            raise ValueError("DragGizmoAxis: 2D scene view not available")

        origin = view._gizmo_origin_screen()
        if origin is None:
            raise ValueError("DragGizmoAxis: no gizmo origin (selection not in 2D view?)")
        ox, oy = origin
        # Press a short distance along the axis (inside the pick radius), drag delta px.
        grab = 20.0
        if step.axis == "x":
            start = (ox + grab, oy)
            end = (ox + grab + step.delta, oy)
        elif step.axis == "y":
            start = (ox, oy + grab)
            end = (ox, oy + grab + step.delta)
        else:
            raise ValueError(f"DragGizmoAxis: axis must be 'x' or 'y', got {step.axis!r}")

        # Expected position: the translate gizmo snaps the dragged axis to the
        # cursor's canvas coordinate (honouring the view's grid snap), leaving
        # the other axis at its start value.
        cx, cy = view._screen_to_canvas(*end)
        cx, cy = view._snap(cx), view._snap(cy)
        sp = node.position
        step._start_pos = (float(sp.x), float(sp.y))
        if step.axis == "x":
            step._expected = (cx, float(sp.y))
        else:
            step._expected = (float(sp.x), cy)

        _init_drag(step, runner, start, end)
        step._phase = 1
    elif step._phase == 1:
        if _drive_drag(runner, step, dt):
            node = editor.state.find_node(step.path)
            ax, ay = float(node.position.x), float(node.position.y)
            ex, ey = step._expected
            if abs(ax - ex) > step.tolerance or abs(ay - ey) > step.tolerance:
                msg = (
                    f"DragGizmoAxis: {step.path} expected ~({ex:.1f}, {ey:.1f}) "
                    f"after dragging {step.axis} from {step._start_pos}, got ({ax:.1f}, {ay:.1f})"
                )
                runner._failures.append(msg)
                runner._failed = True
                if runner._test_mode:
                    raise AssertionError(msg)
            runner._advance()


# ============================================================================
# File-browser double-click: opens a file via two simulated clicks
# ============================================================================
def _find_file_browser(editor):
    """Return the FileBrowserPanel if present, else None."""
    from simvx.editor.panels.file_browser import FileBrowserPanel

    panel = getattr(editor, "_file_browser_content", None)
    return panel if isinstance(panel, FileBrowserPanel) else None


def _file_row_centre(panel, filename: str):
    """Return the screen-centre of the visible tree row whose basename matches."""
    import os

    tree_view = panel._tree_view
    for item, _rx, ry, _depth in tree_view._row_map:
        data = getattr(item, "data", None)
        if not data:
            continue
        path = data.get("path", "")
        if path and os.path.basename(path) == filename:
            gx, _gy, gw, _gh = panel.get_global_rect()
            return (gx + gw / 2.0, ry + tree_view.row_height / 2.0), path
    return None, None


def _activate_left_tab(editor, panel):
    """Make *panel* the active tab in the left dock so it becomes visible.

    Routes through the TabContainer's public ``current_tab`` setter (the same
    state a tab click updates), so the panel draws and its tree-view rows
    populate. Returns True if the tab was found.
    """
    tabs = editor._left_tabs
    if tabs is None:
        return False
    from simvx.core import Control

    controls = [c for c in tabs.children if isinstance(c, Control)]
    if panel in controls:
        idx = controls.index(panel)
        if tabs.current_tab != idx:
            tabs.current_tab = idx
            tabs._update_layout()
            tabs.tab_changed.emit(idx)
        return True
    return False


def _handle_double_click_file(runner: DemoRunner, step: DoubleClickFile, dt: float):
    runner._action_desc = f"DoubleClickFile: {step.filename}"
    editor = _get_editor(runner)
    if step._phase == 0:
        if editor is None:
            raise ValueError("DoubleClickFile: no editor")
        panel = _find_file_browser(editor)
        if panel is None:
            raise ValueError("DoubleClickFile: file browser panel not available")
        _activate_left_tab(editor, panel)
        panel.refresh()
        # Render one frame so the (now-visible) tree-view populates its hit-rects.
        if runner._tree is not None:
            from simvx.core.ui.testing import DrawLog

            runner._tree.render(DrawLog())
        centre, path = _file_row_centre(panel, step.filename)
        if centre is None:
            raise ValueError(f"DoubleClickFile: file {step.filename!r} not found in browser")
        # Capture activation through the public signals (input-only verification).
        panel.file_activated.connect(lambda p, s=step: s._activated.append(("activated", p)))
        panel.file_opened.connect(lambda p, s=step: s._activated.append(("opened", p)))
        step._panel_ref = panel
        step._mc_origin = runner._cursor_pos
        step._mc_target = centre
        step._mc_phase = 0
        step._mc_t = 0.0
        step._phase = 1
    elif step._phase == 1:
        if _drive_double_click(runner, step, dt):
            import os

            hits = [p for _kind, p in step._activated]
            ok = any(os.path.basename(p) == step.filename for p in hits)
            if not ok:
                msg = f"DoubleClickFile: {step.filename!r} did not activate (signals: {step._activated})"
                runner._failures.append(msg)
                runner._failed = True
                if runner._test_mode:
                    raise AssertionError(msg)
            runner._advance()


# ============================================================================
# Code-editor click-to-caret: clicks at a (line, col) and verifies the caret
# ============================================================================
def _handle_click_code_caret(runner: DemoRunner, step: ClickCodeCaret, dt: float):
    runner._action_desc = f"ClickCaret: ({step.line}, {step.col})"
    editor = _get_editor(runner)
    if step._phase == 0:
        if editor is None or not editor._script_tabs:
            raise ValueError("ClickCodeCaret: no editor / script tabs")
        ed = editor._script_tabs.current_editor
        if ed is None:
            raise ValueError("ClickCodeCaret: no active code editor")
        step._editor_ref = ed
        # Compute the screen position of the target (line, col) using the same
        # geometry the editor uses to lay out glyphs (DrawLog: 8px/char @ scale 1).
        from simvx.core.ui.testing import DrawLog

        renderer = DrawLog()
        gx, _gy, _gw, _gh = ed.get_global_rect()
        content_x = ed._content_x(renderer)
        line = max(0, min(step.line, len(ed._lines) - 1))
        col = max(0, min(step.col, len(ed._lines[line])))
        target_x = content_x + renderer.text_width(ed._lines[line][:col], ed._font_scale())
        # Click mid-row so the y maps unambiguously to the line.
        target_y = ed._line_y(line) + ed._line_height() / 2.0
        _init_click(step, runner, (target_x, target_y))
        step._phase = 1
    elif step._phase == 1:
        if _drive_click(runner, step, dt):
            # The editor resolves the pending click on its next draw; render one
            # frame so _cursor_line / _cursor_col update through the normal path.
            if runner._tree is not None:
                from simvx.core.ui.testing import DrawLog

                runner._tree.render(DrawLog())
            ed = step._editor_ref
            cl, cc = ed._cursor_line, ed._cursor_col
            if cl != step.line or cc != step.col:
                msg = f"ClickCodeCaret: expected caret ({step.line}, {step.col}), got ({cl}, {cc})"
                runner._failures.append(msg)
                runner._failed = True
                if runner._test_mode:
                    raise AssertionError(msg)
            runner._advance()