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