"""EditorCommandPalette — Ctrl+Shift+P overlay for quick command access.
Ported from the IDE's CommandPalette, adapted for the editor context.
Full-width overlay at top of editor with fuzzy-filtered command list.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from simvx.core import Vec2
from simvx.core.ui.core import Control, UIInputEvent
if TYPE_CHECKING:
from .state import EditorState
# Layout constants
_WIDTH = 550.0
_INPUT_H = 32.0
_ROW_H = 26.0
_MAX_VISIBLE = 20
_PAD = 8.0
# Colour palette
_BG = (0.15, 0.15, 0.16, 1.0)
_INPUT_BG = (0.1, 0.1, 0.11, 1.0)
_BORDER = (0.3, 0.3, 0.3, 1.0)
_TEXT = (0.9, 0.9, 0.9, 1.0)
_DIM = (0.5, 0.5, 0.5, 1.0)
_SELECTED_BG = (0.2, 0.45, 0.8, 1.0)
_OVERLAY_BG = (0.0, 0.0, 0.0, 0.65)
_FONT_SIZE = 14.0
# ---------------------------------------------------------------------------
# Fuzzy matching
# ---------------------------------------------------------------------------
[docs]
def fuzzy_score(query: str, target: str) -> int:
"""Simple fuzzy match scoring. Returns -1 if no match, else lower is better.
Characters in *query* must appear in order within *target* (case-insensitive).
Consecutive matches, word-boundary matches, and start-of-string matches
receive bonuses (lower score).
"""
if not query:
return 0
qi = 0
score = 0
last_match = -1
for ti, ch in enumerate(target):
if qi < len(query) and ch == query[qi]:
# Bonus for consecutive matches
if last_match >= 0 and ti == last_match + 1:
score -= 1
# Bonus for match at start of string
if ti == 0:
score -= 2
# Bonus for match after separator (word boundary)
if ti > 0 and target[ti - 1] in " :_-/":
score -= 1
last_match = ti
qi += 1
if qi < len(query):
return -1 # not all query chars matched
# Penalise by length difference
score += len(target) - len(query)
return score
# ---------------------------------------------------------------------------
# Command registry
# ---------------------------------------------------------------------------
[docs]
@dataclass
class EditorCommand:
"""A single editor command that can be executed from the palette."""
name: str
callback: Callable
shortcut: str = ""
category: str = ""
[docs]
class CommandRegistry:
"""Central registry of named editor commands."""
def __init__(self) -> None:
self._commands: list[EditorCommand] = []
self._by_name: dict[str, EditorCommand] = {}
[docs]
def register(self, name: str, callback: Callable, shortcut: str = "", category: str = "") -> None:
"""Register a command. Duplicate names overwrite the previous entry."""
cmd = EditorCommand(name=name, callback=callback, shortcut=shortcut, category=category)
if name in self._by_name:
old = self._by_name[name]
self._commands = [c for c in self._commands if c is not old]
self._commands.append(cmd)
self._by_name[name] = cmd
[docs]
def get_commands(self) -> list[EditorCommand]:
return list(self._commands)
[docs]
def execute(self, name: str) -> bool:
"""Execute a command by name. Returns True if found and called."""
cmd = self._by_name.get(name)
if cmd is None:
return False
cmd.callback()
return True
[docs]
def search(self, query: str) -> list[EditorCommand]:
"""Fuzzy-search commands by name. Returns matches sorted by relevance."""
if not query:
return list(self._commands[:_MAX_VISIBLE])
q = query.lower()
scored: list[tuple[int, EditorCommand]] = []
for cmd in self._commands:
s = fuzzy_score(q, cmd.name.lower())
if s >= 0:
scored.append((s, cmd))
scored.sort(key=lambda t: t[0])
return [cmd for _, cmd in scored[:_MAX_VISIBLE]]
[docs]
def __len__(self) -> int:
return len(self._commands)
# ---------------------------------------------------------------------------
# EditorCommandPalette widget
# ---------------------------------------------------------------------------
[docs]
class EditorCommandPalette(Control):
"""Floating command palette overlay for the editor (Ctrl+Shift+P).
Renders as a centered overlay with a text filter and scrollable
command list. Fuzzy-matches registered commands by name, displays
keyboard shortcuts right-aligned.
"""
def __init__(self, state: EditorState | None = None, **kwargs):
super().__init__(**kwargs)
self._state = state
self.visible = False
self.z_index = 2000
self.registry = CommandRegistry()
self._query: str = ""
self._filtered: list[EditorCommand] = []
self._selected_index: int = 0
self._cursor_blink: float = 0.0
self.size = Vec2(0, 0)
# -- Public API --------------------------------------------------------
[docs]
def register_command(self, name: str, callback: Callable, shortcut: str = "", category: str = "") -> None:
"""Convenience proxy to the registry."""
self.registry.register(name, callback, shortcut=shortcut, category=category)
[docs]
def show(self):
"""Open the palette."""
self.visible = True
self._query = ""
self._selected_index = 0
self._update_filter()
self.set_focus()
if self._tree:
self._tree.push_popup(self)
[docs]
def hide(self):
"""Close the palette."""
if not self.visible:
return
self.visible = False
self._query = ""
self._filtered.clear()
self.release_focus()
if self._tree:
self._tree.pop_popup(self)
[docs]
def toggle(self):
"""Toggle open/closed."""
if self.visible:
self.hide()
else:
self.show()
# -- Filtering ---------------------------------------------------------
def _update_filter(self):
self._filtered = self.registry.search(self._query)
self._selected_index = min(self._selected_index, max(0, len(self._filtered) - 1))
# -- Popup protocol ----------------------------------------------------
[docs]
def is_popup_point_inside(self, point) -> bool:
"""Modal popup — capture all clicks."""
return self.visible
[docs]
def dismiss_popup(self):
self.hide()
# -- Input -------------------------------------------------------------
def _on_gui_input(self, event: UIInputEvent):
if not self.visible:
return
if event.key == "escape" and event.pressed:
self.hide()
return
if event.key == "enter" and event.pressed:
self._execute_selected()
return
if event.key == "up" and event.pressed:
self._selected_index = max(0, self._selected_index - 1)
return
if event.key == "down" and event.pressed:
self._selected_index = min(len(self._filtered) - 1, self._selected_index + 1)
return
if event.key == "backspace" and event.pressed:
if self._query:
self._query = self._query[:-1]
self._update_filter()
return
if event.char and len(event.char) == 1:
self._query += event.char
self._update_filter()
def _execute_selected(self):
if not self._filtered:
self.hide()
return
cmd = self._filtered[self._selected_index]
self.hide()
cmd.callback()
# -- Process / Draw ----------------------------------------------------
[docs]
def process(self, dt: float):
if self.visible:
self._cursor_blink += dt
if self._cursor_blink > 1.0:
self._cursor_blink = 0.0
[docs]
def draw(self, renderer):
# Normal draw pass — skip; we draw in popup pass
pass
[docs]
def draw_popup(self, renderer):
if not self.visible:
return
ss = self._get_parent_size()
sw, sh = ss.x, ss.y
scale = _FONT_SIZE / 14.0
# Overlay backdrop
renderer.draw_filled_rect(0, 0, sw, sh, _OVERLAY_BG)
# Palette position: centred horizontally, 1/4 from top
num_results = min(len(self._filtered), _MAX_VISIBLE)
total_h = _INPUT_H + num_results * _ROW_H + 4
px = (sw - _WIDTH) / 2
py = sh * 0.25
# Background + border
renderer.draw_filled_rect(px, py, _WIDTH, total_h, _BG)
renderer.draw_rect_coloured(px, py, _WIDTH, total_h, _BORDER)
# Input field
renderer.draw_filled_rect(px + 2, py + 2, _WIDTH - 4, _INPUT_H - 2, _INPUT_BG)
display_text = f"> {self._query}"
text_y = py + (_INPUT_H - _FONT_SIZE) / 2
renderer.draw_text_coloured(display_text, px + _PAD, text_y, scale, _TEXT)
# Cursor blink
if self._cursor_blink < 0.5:
cursor_x = px + _PAD + renderer.text_width(display_text, scale)
renderer.draw_line_coloured(cursor_x, py + 6, cursor_x, py + _INPUT_H - 6, _TEXT)
# Results
ry = py + _INPUT_H
for i, cmd in enumerate(self._filtered[:_MAX_VISIBLE]):
if i == self._selected_index:
renderer.draw_filled_rect(px + 2, ry, _WIDTH - 4, _ROW_H, _SELECTED_BG)
# Command name
renderer.draw_text_coloured(cmd.name, px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2, scale, _TEXT)
# Shortcut (right-aligned)
if cmd.shortcut:
kw = renderer.text_width(cmd.shortcut, scale)
renderer.draw_text_coloured(
cmd.shortcut, px + _WIDTH - kw - _PAD, ry + (_ROW_H - _FONT_SIZE) / 2, scale, _DIM
)
ry += _ROW_H
# ---------------------------------------------------------------------------
# Command registration helper
# ---------------------------------------------------------------------------
[docs]
def register_editor_commands(palette: EditorCommandPalette, state: EditorState) -> None:
"""Populate the palette with standard editor commands derived from menus."""
from . import menus as _m
reg = palette.register_command
# File
reg("File: New Scene", lambda: state.new_scene_requested.emit(), shortcut="Ctrl+N", category="File")
reg("File: New File", lambda: _m._new_file(state), shortcut="Ctrl+Shift+N", category="File")
reg("File: Open Scene", lambda: _m._open_scene(state), shortcut="Ctrl+O", category="File")
reg("File: Save Scene", lambda: state.save_scene(), shortcut="Ctrl+S", category="File")
reg("File: Save Scene As", lambda: _m._save_scene_as(state), shortcut="Ctrl+Shift+S", category="File")
# Edit
reg("Edit: Undo", state.undo_stack.undo, shortcut="Ctrl+Z", category="Edit")
reg("Edit: Redo", state.undo_stack.redo, shortcut="Ctrl+Shift+Z", category="Edit")
reg("Edit: Delete", lambda: _m._delete(state), shortcut="Delete", category="Edit")
reg("Edit: Duplicate", lambda: _duplicate(state), shortcut="Ctrl+D", category="Edit")
reg("Edit: Copy", lambda: _m._copy(state), shortcut="Ctrl+C", category="Edit")
reg("Edit: Paste", lambda: _m._paste(state), shortcut="Ctrl+V", category="Edit")
reg("Edit: Cut", lambda: _m._cut(state), shortcut="Ctrl+X", category="Edit")
reg("Edit: Select All", lambda: _m._select_all(state), shortcut="Ctrl+A", category="Edit")
# Scene
reg("Scene: Add Node", lambda: state.add_node_requested.emit(), category="Scene")
reg("Scene: Run Scene", state.play_scene, shortcut="F5", category="Scene")
reg("Scene: Stop Scene", state.stop_scene, shortcut="F6", category="Scene")
reg("Scene: Pause Scene", state.pause_scene, shortcut="F7", category="Scene")
# View
reg("View: Toggle 3D Viewport", lambda: _set_viewport(state, "3d"), category="View")
reg("View: Toggle 2D Viewport", lambda: _set_viewport(state, "2d"), category="View")
reg("View: Toggle Script Editor", lambda: _set_viewport(state, "code"), category="View")
reg("View: Toggle Grid", lambda: _toggle_grid(state), category="View")
# Gizmo
reg("Gizmo: Translate Mode", lambda: _set_gizmo(state, "translate"), category="Gizmo")
reg("Gizmo: Rotate Mode", lambda: _set_gizmo(state, "rotate"), category="Gizmo")
reg("Gizmo: Scale Mode", lambda: _set_gizmo(state, "scale"), category="Gizmo")
# ---------------------------------------------------------------------------
# Action helpers
# ---------------------------------------------------------------------------
def _duplicate(state: EditorState) -> None:
node = state.selection.primary
if node:
state.duplicate_node(node)
def _set_viewport(state: EditorState, mode: str) -> None:
state.viewport_mode = mode
state.viewport_mode_changed.emit()
def _toggle_grid(state: EditorState) -> None:
state.show_grid_3d = not state.show_grid_3d
def _set_gizmo(state: EditorState, mode: str) -> None:
from simvx.core import GizmoMode
modes = {"translate": GizmoMode.TRANSLATE, "rotate": GizmoMode.ROTATE, "scale": GizmoMode.SCALE}
state.gizmo.mode = modes.get(mode, GizmoMode.TRANSLATE)