"""Editor Menus: Menu bar setup and keyboard shortcuts."""
from collections.abc import Callable
from simvx.core import (
Input,
MenuBar,
MenuItem,
)
from .state import State
_MODIFIERS: frozenset[str] = frozenset({"ctrl", "shift", "alt"})
def _split_combo(combo: str) -> tuple[str, set[str]]:
"""Split a combo string like ``"ctrl+shift+z"`` into ``("z", {"ctrl", "shift"})``.
A bare key (``"s"``) returns ``("s", set())``. Casing is normalised to
lower; whitespace around ``+`` is tolerated. The base key is whichever
part is not in ``{ctrl, shift, alt}``.
"""
parts = [p.strip().lower() for p in combo.split("+")]
mods: set[str] = set()
base: str = ""
for part in parts:
if part in _MODIFIERS:
mods.add(part)
else:
base = part
return base, mods
[docs]
def make_shortcut_listener(state: State) -> Callable[[str], bool]:
"""Build a ``_shortcut_handler`` callable wired to ``state.shortcuts``.
The returned function is suitable for installation on a
:class:`UIInputManager` via ``tree._shortcut_handler = ...``. It reads
currently-held modifier keys from :class:`Input` so the GLFW/SDL3
platform path (which sets ``Input._keys["ctrl"]=True`` before firing
the ``ui_input(key=...)`` event) works without any extra plumbing.
Accepts either a bare key (``"s"``) or a combo string with embedded
modifiers (``"shift+tab"``), and unions both modifier sources.
"""
def handler(key: str) -> bool:
base, mods = _split_combo(key)
if not base:
return False
if Input._keys.get("ctrl"):
mods.add("ctrl")
if Input._keys.get("shift"):
mods.add("shift")
if Input._keys.get("alt"):
mods.add("alt")
return bool(state.shortcuts.handle_key(base, modifiers=mods))
return handler
[docs]
def register_shortcuts(state: State):
"""Register all editor keyboard shortcuts."""
s = state.shortcuts
# File
s.register("new_scene", "Ctrl+N", lambda: state.new_scene_requested.emit())
s.register("open_scene", "Ctrl+O", lambda: _open_scene(state))
s.register("save_scene", "Ctrl+S", lambda: state.save_scene())
s.register("save_scene_as", "Ctrl+Shift+S", lambda: _save_scene_as(state))
s.register("close_scene", "Ctrl+W", lambda: _close_scene(state))
s.register("export_project", "Ctrl+E", lambda: state.export_requested.emit())
# Edit
s.register("undo", "Ctrl+Z", state.undo_stack.undo)
s.register("redo", "Ctrl+Shift+Z", state.undo_stack.redo)
s.register("delete", "Delete", lambda: _delete(state))
# Scene
s.register("play", "F5", state.play_scene)
s.register("stop", "F6", state.stop_scene)
s.register("pause", "F7", state.pause_scene)
# Gizmo
s.register("gizmo_cycle", "Q", state.gizmo.cycle_mode)
# ---- Action implementations ----
def _open_scene(state: State):
"""Open scene file dialog."""
state._show_open_dialog()
def _save_scene_as(state: State):
"""Save scene with file dialog."""
state._show_save_as_dialog()
def _quit(state: State):
"""Quit the editor cleanly.
SystemExit is caught by the try/finally in Engine.run(),
which ensures proper Vulkan cleanup before process exit.
"""
raise SystemExit(0)
def _cut(state: State):
"""Cut selected node."""
node = state.selection.primary
if node:
state.clipboard.copy_node(node)
state.remove_node(node)
def _copy(state: State):
"""Copy selected node."""
node = state.selection.primary
if node:
state.clipboard.copy_node(node)
def _paste(state: State):
"""Paste node from clipboard."""
if state.clipboard.has_node():
parent = state.selection.primary or (state.edited_scene.root if state.edited_scene else None)
if parent:
node = state.clipboard.paste_node()
if node:
state.add_node(node, parent)
def _delete(state: State):
"""Delete selected node."""
node = state.selection.primary
if node and node.parent:
state.remove_node(node)
state.selection.clear()
def _select_all(state: State):
"""Select all nodes in the scene."""
from simvx.core import Node
root = state.edited_scene.root if state.edited_scene else None
if root:
all_nodes = [root] + root.find_all(Node)
state.selection.select_all(all_nodes)
def _add_node(state: State):
"""Request the Add Node type dialog via the editor state signal."""
state.add_node_requested.emit()
def _instance_scene(state: State):
"""Open file dialog to instance a scene."""
state._show_open_dialog()
def _set_viewport(state: State, mode: str):
"""Switch viewport mode and notify the editor shell."""
state.viewport_mode = mode
state.viewport_mode_changed.emit()
def _close_scene(state: State):
"""Close the currently active scene/script tab."""
state.workspace.close_current_tab()
def _new_untitled(state: State):
"""Open a fresh Untitled scratch buffer (VS Code "Untitled-N" pattern).
The buffer lives purely in editor memory: it is **not** persisted to any
Node attribute, scene file, or session file. Save (Ctrl+S) prompts for a
destination path; once written the tab becomes a regular file-backed tab.
"""
state.workspace.new_untitled()
def _new_project(state: State):
"""Request transition to WelcomeScreen for creating a new project."""
state.new_project_requested.emit()
def _open_project(state: State):
"""Request transition to WelcomeScreen for opening a project."""
state.open_project_requested.emit()
def _build_recent_submenu(state: State) -> list[MenuItem]:
"""Build the Recent Projects submenu items from the project registry."""
from .project_registry import ProjectRegistry
registry = ProjectRegistry()
registry.load()
items = []
for entry in registry.recent[:5]:
path = entry.path
items.append(MenuItem(entry.name, callback=lambda p=path: _switch_project(state, p)))
if not items:
items.append(MenuItem("(No recent projects)"))
return items
def _switch_project(state: State, project_path: str):
"""Signal the shell to switch to a specific project."""
state._pending_switch_project = project_path
state.open_project_requested.emit()
def _reset_layout(state: State):
"""Reset dock layout to default split ratios."""
from simvx.core import SplitContainer
dock = getattr(state, "_dock_container", None)
if dock is None:
return
def _reset(node):
for child in getattr(node, "children", []):
if isinstance(child, SplitContainer):
child.split_ratio = 0.25 if child.vertical else 0.75
child._update_layout()
_reset(child)
_reset(dock)