Private
Public Access
0
0
Files
manual_slop/src/command_palette.py
T
ed d7449ae417 feat(palette): add Up/Down arrow navigation and Enter key selection
- Process arrow keys BEFORE input_text so the input field does not consume them
- Up/Down arrow keys navigate the result list (clamped to bounds)
- Enter and KeypadEnter execute the currently selected command
- Refactored _close_palette and _execute helpers (action call is now wrapped in try/except via _execute)
- Added 3 new tests: close helper resets state, execute runs and catches exceptions, top_n is meaningful for navigation
2026-06-02 22:41:59 -04:00

200 lines
5.9 KiB
Python

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()