"""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 -----------------------------------------------------------------
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