Source code for simvx.ide.widgets.command_palette

"""CommandPalette -- floating overlay for command/file search (Ctrl+Shift+P)."""

import logging
import os
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING

from simvx.core import PointerFilter, Signal, on_gui_input
from simvx.core.descriptors import Property
from simvx.core.input import Key, Pointer, PointerEvent, PointerPhase
from simvx.core.math.types import Vec2
from simvx.core.ui.core import Control
from simvx.core.ui.enums import Anchor
from simvx.core.ui.theme import get_theme
from simvx.core.ui.widgets import TextEdit

if TYPE_CHECKING:
    from ..config import Config
    from ..state import State

log = logging.getLogger(__name__)

_WIDTH = 500.0
_INPUT_H = 32.0
_ROW_H = 26.0
_MAX_VISIBLE = 20
_PAD = 8.0

_FONT_SIZE = 14.0

_SKIP_DIRS = {
    ".git",
    "__pycache__",
    ".mypy_cache",
    ".ruff_cache",
    "node_modules",
    ".venv",
    "venv",
    ".tox",
    "dist",
    "build",
    ".eggs",
    "*.egg-info",
}


class _Command:
    __slots__ = ("name", "callback", "keybinding")

    def __init__(self, name: str, callback: Callable, keybinding: str = ""):
        self.name = name
        self.callback = callback
        self.keybinding = keybinding


class _QueryField(TextEdit):
    """The palette's query line: Up and Down walk the results and never leave the field."""

    stepped = Signal(int)

    @on_gui_input(Key.UP, repeat=True)
    def _previous(self, event) -> bool:
        self.stepped.emit(-1)
        return True

    @on_gui_input(Key.DOWN, repeat=True)
    def _next(self, event) -> bool:
        self.stepped.emit(1)
        return True


[docs] class CommandPalette(Control): """Floating command palette with fuzzy search. Supports two modes: - Command mode (default): search registered commands by name - File mode: search project files by name """ pointer_filter = Property(PointerFilter.STOP, hint="How this control takes part in the pointer hit test") top_level = Property(True, on_change="_invalidate_transform", hint="Placed on the screen, not by its parent") visible = Property( False, coerce=bool, hint="The palette is up only between show() and its answer", on_change="_on_visible_changed", ) # Above every panel: the palette is what the user is looking at. z_index = Property(2000, range=(-4096, 4096), hint="Draw order (higher = on top)", on_change="_invalidate_z_cache") # Zero because the palette lays itself out against the parent rect in # ``on_draw`` and never reads its own size. size_x = Property(0.0, range=(0, 10000), hint="Control width", on_change="_on_size_changed") size_y = Property(0.0, range=(0, 10000), hint="Control height", on_change="_on_size_changed") anchor = Property(Anchor.FULL_RECT, group="Layout", hint="A full-screen overlay unless the caller places it") def __init__(self, state: State, config: Config, **kwargs): super().__init__(**kwargs) self._state = state self._config = config self.cancel_requested.connect(self._on_router_cancel) self._commands: list[_Command] = [] self._filtered: list[_Command | str | tuple] = [] self._selected_index: int = 0 self._file_mode: bool = False self._symbol_mode: bool = False self._symbols: list[tuple[str, str, int]] = [] self._field = self.add_child(_QueryField(name="Query")) self._field.text_changed.connect(self._on_query_changed) self._field.text_submitted.connect(self._on_query_submitted) self._field.stepped.connect(self._step) # -- Public API ------------------------------------------------------------
[docs] def register_command(self, name: str, callback: Callable, keybinding: str = ""): self._commands.append(_Command(name, callback, keybinding))
[docs] def show(self, file_mode: bool = False, *, symbol_mode: bool = False, symbols: list | None = None): self._field.text = "" self._field.cursor_pos = 0 self._selected_index = 0 self._file_mode = file_mode self._symbol_mode = symbol_mode self._symbols = symbols or [] self._update_filter() self._layout() self.show_overlay("blocking", initial_focus=self._field)
[docs] def hide(self): if not self.visible: return self._filtered.clear() self._symbol_mode = False self._symbols.clear() self.close_overlay() self.visible = False
def _on_router_cancel(self): if self.visible: self.hide() # -- Filtering ------------------------------------------------------------- def _update_filter(self): query = self._field.text if self._symbol_mode: self._filtered = self._search_symbols(query) elif self._file_mode: self._filtered = self._search_files(query) else: self._filtered = self._search_commands(query) self._selected_index = min(self._selected_index, max(0, len(self._filtered) - 1)) def _search_commands(self, query: str) -> list[_Command]: if not query: return list(self._commands[:_MAX_VISIBLE]) q = query.lower() scored: list[tuple[int, _Command]] = [] for cmd in self._commands: score = _fuzzy_score(q, cmd.name.lower()) if score >= 0: scored.append((score, cmd)) scored.sort(key=lambda t: t[0]) return [cmd for _, cmd in scored[:_MAX_VISIBLE]] def _search_files(self, query: str) -> list[str]: root = self._state.project_root if not root or not Path(root).is_dir(): return [] q = query.lower() matches: list[tuple[int, str]] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")] for fname in filenames: if fname.startswith("."): continue score = _fuzzy_score(q, fname.lower()) if q else 0 if score >= 0: full = str(Path(dirpath) / fname) matches.append((score, full)) if len(matches) > 500: break if len(matches) > 500: break matches.sort(key=lambda t: t[0]) return [p for _, p in matches[:_MAX_VISIBLE]] def _search_symbols(self, query: str) -> list[tuple[str, str, int]]: if not query: return list(self._symbols[:_MAX_VISIBLE]) q = query.lower() scored: list[tuple[int, tuple[str, str, int]]] = [] for sym in self._symbols: score = _fuzzy_score(q, sym[0].lower()) if score >= 0: scored.append((score, sym)) scored.sort(key=lambda t: t[0]) return [s for _, s in scored[:_MAX_VISIBLE]] # -- Input -----------------------------------------------------------------
[docs] def on_gui_input(self, event): # Outside-click on the palette card dismisses. The palette does not # scroll: the result list is capped at the number it draws, so a wheel # notch over it belongs to whatever is behind. if not self.visible or not isinstance(event, PointerEvent): return None if event.button in Pointer.PRIMARY and event.phase is PointerPhase.DOWN: px, py, total_h = self._card() ex, ey = event.position if not (px <= ex <= px + _WIDTH and py <= ey <= py + total_h): self.hide() return True return None
def _on_query_changed(self, _text: str): self._update_filter() def _on_query_submitted(self, _text: str): self._execute_selected() def _step(self, delta: int): self._selected_index = max(0, min(len(self._filtered) - 1, self._selected_index + delta)) def _execute_selected(self): if not self._filtered: self.hide() return item = self._filtered[self._selected_index] self.hide() if isinstance(item, _Command): item.callback() elif isinstance(item, tuple) and len(item) == 3: # Symbol: (name, kind, line) self._state.goto_requested.emit(self._state.active_file or "", item[2], 0) elif isinstance(item, str): # File path -- open it self._state.goto_requested.emit(item, 0, 0) # -- Layout / Draw --------------------------------------------------------- def _card(self) -> tuple[float, float, float]: """The palette card's left, top and height: centred horizontally, a quarter of the way down.""" ss = self._get_parent_size() total_h = _INPUT_H + min(len(self._filtered), _MAX_VISIBLE) * _ROW_H + 4 return (ss.x - _WIDTH) / 2, ss.y * 0.25, total_h def _layout(self): px, py, _ = self._card() self._field.position = Vec2(px + 2, py + 2) self._field.size = Vec2(_WIDTH - 4, _INPUT_H - 2)
[docs] def on_update(self, dt: float): if self.visible: self._layout()
[docs] def on_draw(self, renderer): if not self.visible: return theme = get_theme() # The "blocking" overlay's dim scrim is painted by the collector; we only # render the palette card on top. px, py, total_h = self._card() scale = _FONT_SIZE / 16.0 renderer.rect((px, py), (_WIDTH, total_h), fill=theme.popup_bg) renderer.rect((px, py), (_WIDTH, total_h), stroke=theme.border_light) # The query field draws itself; the results sit below it. ry = py + _INPUT_H for i, item in enumerate(self._filtered[:_MAX_VISIBLE]): # Selection / hover highlight if i == self._selected_index: renderer.rect((px + 2, ry), (_WIDTH - 4, _ROW_H), fill=theme.selection_bg) if isinstance(item, _Command): # Command name renderer.text(item.name, (px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2), fill=theme.text, scale=scale) # Keybinding (right-aligned) if item.keybinding: kw = renderer.text_width(item.keybinding, scale) renderer.text( item.keybinding, (px + _WIDTH - kw - _PAD, ry + (_ROW_H - _FONT_SIZE) / 2), fill=theme.text_dim, scale=scale, ) elif isinstance(item, tuple) and len(item) == 3: # Symbol: (name, kind, line) name, kind, line = item prefix = "C " if kind == "class" else "f " kind_colour = (0.95, 0.76, 0.19, 1.0) if kind == "class" else (0.4, 0.6, 1.0, 1.0) ty = ry + (_ROW_H - _FONT_SIZE) / 2 renderer.text(prefix, (px + _PAD, ty), fill=kind_colour, scale=scale) prefix_w = renderer.text_width(prefix, scale) renderer.text(name, (px + _PAD + prefix_w, ty), fill=theme.text, scale=scale) line_str = f":{line + 1}" lw = renderer.text_width(line_str, scale) renderer.text(line_str, (px + _WIDTH - lw - _PAD, ty), fill=theme.text_dim, scale=scale) elif isinstance(item, str): # File path -- show relative path rel = self._state.relative_path(item) renderer.text(rel, (px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2), fill=theme.text, scale=scale) ry += _ROW_H
def _fuzzy_score(query: str, target: str) -> int: """Simple fuzzy match scoring. Returns -1 if no match, else lower is better.""" 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 if ti == 0: score -= 2 last_match = ti qi += 1 if qi < len(query): return -1 # not all query chars matched # Penalize by length difference score += len(target) - len(query) return score