Source code for simvx.ide.widgets.command_palette

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


from __future__ import annotations

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

from simvx.core import Vec2
from simvx.core.ui.core import Control, UIInputEvent
from simvx.core.ui.theme import get_theme

if TYPE_CHECKING:
    from ..config import IDEConfig
    from ..state import IDEState

log = logging.getLogger(__name__)

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

_OVERLAY_BG = (0.0, 0.0, 0.0, 0.65)
_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


[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 """ def __init__(self, state: IDEState, config: IDEConfig, **kwargs): super().__init__(**kwargs) self._state = state self._config = config self.visible = False self.z_index = 2000 self._commands: list[_Command] = [] self._query: str = "" 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._cursor_blink: float = 0.0 self.size = Vec2(0, 0) # -- 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.visible = True self._query = "" self._selected_index = 0 self._file_mode = file_mode self._symbol_mode = symbol_mode self._symbols = symbols or [] self._update_filter() self.set_focus() if self._tree: self._tree.push_popup(self)
[docs] def hide(self): if not self.visible: return self.visible = False self._query = "" self._filtered.clear() self._symbol_mode = False self._symbols.clear() self.release_focus() if self._tree: self._tree.pop_popup(self)
# -- Filtering ------------------------------------------------------------- def _update_filter(self): if self._symbol_mode: self._filtered = self._search_symbols(self._query) elif self._file_mode: self._filtered = self._search_files(self._query) else: self._filtered = self._search_commands(self._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]] # -- 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 to our normal handler.""" # Click outside the palette area → dismiss 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): """Dismiss when clicking outside.""" 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 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) # -- 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 # Auto-hide if focus was stolen (e.g., by clicking elsewhere during resize) if not self.focused and self._tree and self._tree._focused_control is not self: self.hide()
[docs] def draw(self, renderer): # Don't draw in normal pass — we draw in popup pass instead pass
[docs] def draw_popup(self, renderer): if not self.visible: return theme = get_theme() # Get screen size 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: centered 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, theme.popup_bg) renderer.draw_rect_coloured(px, py, _WIDTH, total_h, theme.border_light) # Input field renderer.draw_filled_rect(px + 2, py + 2, _WIDTH - 4, _INPUT_H - 2, theme.bg_input) prefix = "" if not self._file_mode else "" display_text = prefix + self._query text_y = py + (_INPUT_H - _FONT_SIZE) / 2 renderer.draw_text_coloured(display_text, px + _PAD, text_y, scale, theme.text) # Cursor 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, theme.text) # Results ry = py + _INPUT_H for i, item in enumerate(self._filtered[:_MAX_VISIBLE]): # Selection / hover highlight if i == self._selected_index: renderer.draw_filled_rect(px + 2, ry, _WIDTH - 4, _ROW_H, theme.selection_bg) if isinstance(item, _Command): # Command name renderer.draw_text_coloured(item.name, px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2, scale, theme.text) # Keybinding (right-aligned) if item.keybinding: kw = renderer.text_width(item.keybinding, scale) renderer.draw_text_coloured( item.keybinding, px + _WIDTH - kw - _PAD, ry + (_ROW_H - _FONT_SIZE) / 2, scale, theme.text_dim) 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.draw_text_coloured(prefix, px + _PAD, ty, scale, kind_colour) prefix_w = renderer.text_width(prefix, scale) renderer.draw_text_coloured(name, px + _PAD + prefix_w, ty, scale, theme.text) line_str = f":{line + 1}" lw = renderer.text_width(line_str, scale) renderer.draw_text_coloured(line_str, px + _WIDTH - lw - _PAD, ty, scale, theme.text_dim) elif isinstance(item, str): # File path -- show relative path rel = self._state.relative_path(item) renderer.draw_text_coloured(rel, px + _PAD, ry + (_ROW_H - _FONT_SIZE) / 2, scale, theme.text) 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