from __future__ import annotations from dataclasses import dataclass, field from typing import Optional, Callable, List, Dict, Any @dataclass class Command: id: str title: str category: str shortcut: Optional[str] = None description: str = "" enabled_when: Optional[str] = None action: Optional[Callable] = None @dataclass class ScoredCommand: command: Command score: float class CommandRegistry: def __init__(self) -> None: self._commands: Dict[str, Command] = {} def register(self, command_or_callable: Any) -> Any: if isinstance(command_or_callable, Command): cmd = command_or_callable else: cmd = Command( id=command_or_callable.__name__, title=command_or_callable.__name__.replace("_", " ").title(), category="uncategorized", action=command_or_callable, ) if cmd.id in self._commands: raise ValueError(f"Command {cmd.id} already registered") self._commands[cmd.id] = cmd return command_or_callable def all(self) -> List[Command]: return list(self._commands.values()) def get(self, command_id: str) -> Optional[Command]: return self._commands.get(command_id) def fuzzy_match(query: str, candidates: List[Command], top_n: int = 20) -> List[ScoredCommand]: query_lower = query.lower() scored: List[ScoredCommand] = [] for cmd in candidates: title_lower = cmd.title.lower() if not _is_subsequence(query_lower, title_lower): continue score = _compute_score(query_lower, title_lower) scored.append(ScoredCommand(command=cmd, score=score)) scored.sort(key=lambda r: r.score, reverse=True) return scored[:top_n] def _is_subsequence(query: str, target: str) -> bool: qi = 0 for ch in target: if qi < len(query) and ch == query[qi]: qi += 1 return qi == len(query) def _compute_score(query: str, target: str) -> float: score = 0.0 if target.startswith(query): score += 1.0 elif _starts_at_word_boundary(query, target): score += 0.5 if _is_contiguous(query, target): score += 0.3 gaps = _count_gaps(query, target) score -= 0.1 * gaps return score def _starts_at_word_boundary(query: str, target: str) -> bool: if not target.startswith(query): return False return len(query) == 0 or not query[0].isalnum() or len(target) == len(query) or not target[len(query)].isalnum() def _is_contiguous(query: str, target: str) -> bool: return query in target def _count_gaps(query: str, target: str) -> int: qi = 0 gaps = 0 last_match = -1 for ti, ch in enumerate(target): if qi < len(query) and ch == query[qi]: if last_match >= 0 and ti - last_match > 1: gaps += ti - last_match - 1 last_match = ti qi += 1 return gaps def _close_palette(app: Any) -> None: """Close the palette and reset all per-open state.""" app.show_command_palette = False app._command_palette_query = "" app._command_palette_selected = 0 app._command_palette_focused = False app._command_palette_input_focused = False def _execute(app: Any, command: Command) -> None: """Run a command and close the palette. Catches exceptions to keep the modal clean.""" if not command.action: return try: command.action(app) except Exception as e: print(f"[CommandPalette] Action {command.id} raised: {e}") _close_palette(app) def render_palette_modal(app: Any, commands: List[Command]) -> None: if not getattr(app, "show_command_palette", False): return from imgui_bundle import imgui viewport = imgui.get_main_viewport() center = viewport.get_center() imgui.set_next_window_pos((center.x - 300, center.y - 200), imgui.Cond_.always) imgui.set_next_window_size((600, 400), imgui.Cond_.always) if not hasattr(app, "_command_palette_query"): app._command_palette_query = "" if not hasattr(app, "_command_palette_selected"): app._command_palette_selected = 0 if not hasattr(app, "_command_palette_focused"): app._command_palette_focused = False # Set focus on the window + input field ONCE per open. if not app._command_palette_focused: imgui.set_next_window_focus() app._command_palette_focused = True # Escape closes the palette. if imgui.is_key_pressed(imgui.Key.escape): _close_palette(app) return expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse) if not expanded or not opened: app.show_command_palette = False app._command_palette_focused = False imgui.end() return # After the window is drawn, the input gets focus. if not getattr(app, '_command_palette_input_focused', False): imgui.set_keyboard_focus_here() app._command_palette_input_focused = True # Process Up/Down/Enter BEFORE input_text so we see the keys before the # input field consumes them for cursor movement / text editing. results = fuzzy_match(app._command_palette_query, commands, top_n=20) if results: app._command_palette_selected = max(0, min(app._command_palette_selected, len(results) - 1)) else: app._command_palette_selected = 0 if imgui.is_key_pressed(imgui.Key.down_arrow): if results: app._command_palette_selected = min(app._command_palette_selected + 1, len(results) - 1) if imgui.is_key_pressed(imgui.Key.up_arrow): if results: app._command_palette_selected = max(app._command_palette_selected - 1, 0) if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter): if results and 0 <= app._command_palette_selected < len(results): _execute(app, results[app._command_palette_selected].command) imgui.set_next_item_width(-1) _, app._command_palette_query = imgui.input_text("##query", app._command_palette_query) if imgui.begin_child("##results", (0, -1)): for i, scored in enumerate(results): is_selected = (i == app._command_palette_selected) label = f"[{scored.command.category}] {scored.command.title}" clicked, _ = imgui.selectable(label, is_selected) if clicked: app._command_palette_selected = i _execute(app, scored.command) if not results: imgui.text_disabled("No matching commands.") imgui.end_child() imgui.end()