Source code for simvx.editor.command_palette

"""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 popup_input(self, event): """Route popup input; click outside dismisses.""" if event.button == 1 and event.pressed: ss = self._get_parent_size() num_results = min(len(self._filtered), _MAX_VISIBLE) total_h = _INPUT_H + num_results * _ROW_H + 4 px = (ss.x - _WIDTH) / 2 py = ss.y * 0.25 ex = event.position.x if hasattr(event.position, "x") else event.position[0] ey = event.position.y if hasattr(event.position, "y") else event.position[1] if not (px <= ex <= px + _WIDTH and py <= ey <= py + total_h): self.hide() return self._on_gui_input(event)
[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)