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