"""CommandPalette — 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.
Supports two modes:
- **command** (Ctrl+Shift+P): fuzzy-search registered editor commands.
- **file** (Ctrl+P): fuzzy-search project files and open in code editor.
"""
import os
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING
from simvx.core import Vec2
from simvx.core.ui.core import Control, UIInputEvent
if TYPE_CHECKING:
from .state import State
# 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
# File extensions shown in quick-open
_FILE_EXTENSIONS = {".py", ".gd", ".json", ".toml", ".cfg", ".txt", ".md", ".yaml", ".yml", ".ini", ".csv", ".xml"}
_IGNORE_DIRS = {".git", "__pycache__", ".mypy_cache", ".pytest_cache", "node_modules", ".venv", "venv", ".ruff_cache"}
# ---------------------------------------------------------------------------
# Palette mode
# ---------------------------------------------------------------------------
[docs]
class PaletteMode(Enum):
"""Operating mode for the command palette."""
COMMAND = auto()
FILE = auto()
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Project file scanner
# ---------------------------------------------------------------------------
[docs]
def scan_project_files(project_path: Path | str | None, max_files: int = 5000) -> list[str]:
"""Walk *project_path* and return relative paths of editable files.
Skips hidden/build/cache directories. Returns at most *max_files* paths
sorted alphabetically.
"""
if project_path is None:
return []
root = Path(project_path)
if not root.is_dir():
return []
results: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
# Prune ignored directories in-place
dirnames[:] = [d for d in dirnames if d not in _IGNORE_DIRS and not d.startswith(".")]
for fn in filenames:
if Path(fn).suffix.lower() in _FILE_EXTENSIONS:
rel = os.path.relpath(os.path.join(dirpath, fn), root)
results.append(rel)
if len(results) >= max_files:
results.sort()
return results
results.sort()
return results
[docs]
def search_files(query: str, files: list[str]) -> list[str]:
"""Fuzzy-search *files* by *query*. Returns top matches sorted by relevance."""
if not query:
return files[:_MAX_VISIBLE]
q = query.lower()
scored: list[tuple[int, str]] = []
for path in files:
# Match against filename and full relative path
filename = os.path.basename(path)
s_name = fuzzy_score(q, filename.lower())
s_path = fuzzy_score(q, path.lower())
# Take the better score (lower is better), prefer filename matches
if s_name != -1 and s_path != -1:
s = min(s_name - 1, s_path) # slight bias toward filename match
elif s_name != -1:
s = s_name - 1
elif s_path != -1:
s = s_path
else:
continue
scored.append((s, len(path), path))
scored.sort(key=lambda t: (t[0], t[1]))
return [path for _, _, path in scored[:_MAX_VISIBLE]]
# ---------------------------------------------------------------------------
# Command registry
# ---------------------------------------------------------------------------
[docs]
@dataclass
class Command:
"""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[Command] = []
self._by_name: dict[str, Command] = {}
[docs]
def register(self, name: str, callback: Callable, shortcut: str = "", category: str = "") -> None:
"""Register a command. Duplicate names overwrite the previous entry."""
cmd = Command(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]
@property
def commands(self) -> list[Command]:
"""All registered commands (defensive copy)."""
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[Command]:
"""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, Command]] = []
for cmd in self._commands:
s = fuzzy_score(q, cmd.name.lower())
if s != -1:
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)
# ---------------------------------------------------------------------------
# CommandPalette widget
# ---------------------------------------------------------------------------
[docs]
class CommandPalette(Control):
"""Floating command palette overlay for the editor.
Supports two modes:
- **command** (Ctrl+Shift+P): fuzzy-search registered editor commands.
- **file** (Ctrl+P): fuzzy-search project files and open in code editor.
Renders as a centered overlay with a text filter and scrollable result list.
"""
def __init__(self, state: State | None = None, **kwargs):
super().__init__(**kwargs)
self._state = state
self.visible = False
self.z_index = 2000
self.registry = CommandRegistry()
self._mode: PaletteMode = PaletteMode.COMMAND
self._query: str = ""
self._filtered: list[Command] = []
self._filtered_files: list[str] = []
self._project_files: list[str] = []
self._selected_index: int = 0
self._cursor_blink: float = 0.0
self.size = Vec2(0, 0)
# Callback for file open (set by Root)
self._on_file_selected: Callable[[str], None] | None = None
# -- 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, mode: PaletteMode = PaletteMode.COMMAND):
"""Open the palette in the given mode."""
self._mode = mode
self.visible = True
self._query = ""
self._selected_index = 0
if mode == PaletteMode.FILE:
self._scan_files()
self._update_file_filter()
else:
self._update_filter()
self.set_focus()
if self._tree:
self._tree.push_popup(self)
[docs]
def show_files(self):
"""Open the palette in file-open mode (Ctrl+P)."""
self.show(PaletteMode.FILE)
[docs]
def hide(self):
"""Close the palette."""
if not self.visible:
return
self.visible = False
self._query = ""
self._filtered.clear()
self._filtered_files.clear()
self.release_focus()
if self._tree:
self._tree.pop_popup(self)
[docs]
def toggle(self):
"""Toggle open/closed (command mode)."""
if self.visible:
self.hide()
else:
self.show()
[docs]
@property
def mode(self) -> PaletteMode:
"""Current palette mode."""
return self._mode
# -- File scanning -----------------------------------------------------
def _scan_files(self):
"""Refresh the project file list from the editor state's project path."""
project_path = self._state.project_path if self._state else None
if project_path is None and self._state:
# Fall back to CWD if no project path set
project_path = Path.cwd()
self._project_files = scan_project_files(project_path)
def _update_file_filter(self):
self._filtered_files = search_files(self._query, self._project_files)
self._selected_index = min(self._selected_index, max(0, len(self._filtered_files) - 1))
# -- 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))
@property
def _result_count(self) -> int:
"""Number of visible results in the current mode."""
if self._mode == PaletteMode.FILE:
return len(self._filtered_files)
return len(self._filtered)
# -- 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(self._result_count - 1, self._selected_index + 1)
return
if event.key == "backspace" and event.pressed:
if self._query:
self._query = self._query[:-1]
self._do_update_filter()
return
if event.char and len(event.char) == 1:
self._query += event.char
self._do_update_filter()
def _do_update_filter(self):
"""Update the appropriate filter based on current mode."""
if self._mode == PaletteMode.FILE:
self._update_file_filter()
else:
self._update_filter()
def _execute_selected(self):
if self._mode == PaletteMode.FILE:
self._execute_file_selected()
else:
self._execute_command_selected()
def _execute_command_selected(self):
if not self._filtered:
self.hide()
return
cmd = self._filtered[self._selected_index]
self.hide()
cmd.callback()
def _execute_file_selected(self):
if not self._filtered_files:
self.hide()
return
rel_path = self._filtered_files[self._selected_index]
self.hide()
if self._on_file_selected:
# Build absolute path from project root
project_path = self._state.project_path if self._state else Path.cwd()
abs_path = str(Path(project_path) / rel_path) if project_path else rel_path
self._on_file_selected(abs_path)
# -- 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_rect((0, 0), (sw, sh), colour=_OVERLAY_BG, filled=True)
# Palette position: centred horizontally, 1/4 from top
num_results = min(self._result_count, _MAX_VISIBLE)
total_h = _INPUT_H + num_results * _ROW_H + 4
px = (sw - _WIDTH) / 2
py = sh * 0.25
# Background + border
renderer.draw_rect((px, py), (_WIDTH, total_h), colour=_BG, filled=True)
renderer.draw_rect((px, py), (_WIDTH, total_h), colour=_BORDER)
# Input field
renderer.draw_rect((px + 2, py + 2), (_WIDTH - 4, _INPUT_H - 2), colour=_INPUT_BG, filled=True)
prompt = ">" if self._mode == PaletteMode.COMMAND else "Open File:"
display_text = f"{prompt} {self._query}"
text_y = py + (_INPUT_H - _FONT_SIZE) / 2
renderer.draw_text(display_text, (px + _PAD, text_y), colour=_TEXT, scale=scale)
# Cursor blink
if self._cursor_blink < 0.5:
cursor_x = px + _PAD + renderer.text_width(display_text, scale)
renderer.draw_line((cursor_x, py + 6), (cursor_x, py + _INPUT_H - 6), colour=_TEXT)
# Results
ry = py + _INPUT_H
if self._mode == PaletteMode.FILE:
self._draw_file_results(renderer, px, ry, scale)
else:
self._draw_command_results(renderer, px, ry, scale)
def _draw_command_results(self, renderer, px: float, ry: float, scale: float):
for i, cmd in enumerate(self._filtered[:_MAX_VISIBLE]):
if i == self._selected_index:
renderer.draw_rect(
(px + 2, ry), (_WIDTH - 4, _ROW_H), colour=_SELECTED_BG, filled=True
)
# Command name
renderer.draw_text(
cmd.name, (px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2), colour=_TEXT, scale=scale
)
# Shortcut (right-aligned)
if cmd.shortcut:
kw = renderer.text_width(cmd.shortcut, scale)
renderer.draw_text(
cmd.shortcut,
(px + _WIDTH - kw - _PAD, ry + (_ROW_H - _FONT_SIZE) / 2),
colour=_DIM, scale=scale,
)
ry += _ROW_H
def _draw_file_results(self, renderer, px: float, ry: float, scale: float):
for i, path in enumerate(self._filtered_files[:_MAX_VISIBLE]):
if i == self._selected_index:
renderer.draw_rect(
(px + 2, ry), (_WIDTH - 4, _ROW_H), colour=_SELECTED_BG, filled=True
)
# Filename (bold) + directory (dim)
filename = os.path.basename(path)
dirname = os.path.dirname(path)
renderer.draw_text(
filename, (px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2), colour=_TEXT, scale=scale
)
if dirname:
fw = renderer.text_width(filename + " ", scale)
renderer.draw_text(
dirname,
(px + _PAD + fw, ry + (_ROW_H - _FONT_SIZE) / 2),
colour=_DIM, scale=scale * 0.9,
)
ry += _ROW_H
# ---------------------------------------------------------------------------
# Command registration helper
# ---------------------------------------------------------------------------
[docs]
def register_editor_commands(palette: CommandPalette, state: State) -> 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 Untitled", lambda: _m._new_untitled(state), category="File")
reg("File: Open Scene", lambda: _m._open_scene(state), shortcut="Ctrl+O", category="File")
reg("File: Open File", lambda: palette.show_files(), shortcut="Ctrl+P", 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")
reg("File: Close Scene", lambda: _m._close_scene(state), shortcut="Ctrl+W", category="File")
reg("File: New Project", lambda: _m._new_project(state), category="File")
reg("File: Open Project", lambda: _m._open_project(state), category="File")
reg("File: Quit", lambda: _m._quit(state), shortcut="Ctrl+Q", 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")
reg("Edit: Preferences", lambda: state.preferences_requested.emit(), category="Edit")
# Scene
reg("Scene: Add Node", lambda: state.add_node_requested.emit(), category="Scene")
reg("Scene: Instance Scene", lambda: _m._instance_scene(state), 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")
reg("View: Reset Layout", lambda: _m._reset_layout(state), category="View")
# Gizmo
reg("Gizmo: Translate Mode", lambda: _set_gizmo(state, "translate"), shortcut="Q", category="Gizmo")
reg("Gizmo: Rotate Mode", lambda: _set_gizmo(state, "rotate"), category="Gizmo")
reg("Gizmo: Scale Mode", lambda: _set_gizmo(state, "scale"), category="Gizmo")
# Help
reg("Help: About SimVX", lambda: state.about_requested.emit(), category="Help")
# ---------------------------------------------------------------------------
# Action helpers
# ---------------------------------------------------------------------------
def _duplicate(state: State) -> None:
node = state.selection.primary
if node:
state.duplicate_node(node)
def _set_viewport(state: State, mode: str) -> None:
state.viewport_mode = mode
state.viewport_mode_changed.emit()
def _toggle_grid(state: State) -> None:
state.show_grid_3d = not state.show_grid_3d
def _set_gizmo(state: State, 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)