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
This commit is contained in:
+40
-15
@@ -103,6 +103,26 @@ def _count_gaps(query: str, target: str) -> int:
|
||||
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
|
||||
@@ -128,8 +148,7 @@ def render_palette_modal(app: Any, commands: List[Command]) -> None:
|
||||
|
||||
# Escape closes the palette.
|
||||
if imgui.is_key_pressed(imgui.Key.escape):
|
||||
app.show_command_palette = False
|
||||
app._command_palette_focused = False
|
||||
_close_palette(app)
|
||||
return
|
||||
|
||||
expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse)
|
||||
@@ -144,11 +163,27 @@ def render_palette_modal(app: Any, commands: List[Command]) -> None:
|
||||
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)
|
||||
|
||||
results = fuzzy_match(app._command_palette_query, commands, top_n=20)
|
||||
|
||||
if imgui.begin_child("##results", (0, -1)):
|
||||
for i, scored in enumerate(results):
|
||||
is_selected = (i == app._command_palette_selected)
|
||||
@@ -156,17 +191,7 @@ def render_palette_modal(app: Any, commands: List[Command]) -> None:
|
||||
clicked, _ = imgui.selectable(label, is_selected)
|
||||
if clicked:
|
||||
app._command_palette_selected = i
|
||||
if scored.command.action:
|
||||
try:
|
||||
scored.command.action(app)
|
||||
except Exception as e:
|
||||
# Don't let a buggy action break the end_child/end pairing.
|
||||
print(f"[CommandPalette] Action {scored.command.id} raised: {e}")
|
||||
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
|
||||
_execute(app, scored.command)
|
||||
if not results:
|
||||
imgui.text_disabled("No matching commands.")
|
||||
imgui.end_child()
|
||||
|
||||
@@ -98,3 +98,66 @@ def test_palette_query_state_resets_on_open(live_gui: Any) -> None:
|
||||
# The internal _command_palette_query is set to "" on open.
|
||||
# We can't directly query it via the hook, but the state being
|
||||
# queryable via show_command_palette confirms the toggle worked.
|
||||
|
||||
|
||||
def test_palette_close_helper_resets_all_state() -> None:
|
||||
"""_close_palette resets all per-open state flags. Pure unit test."""
|
||||
from src.command_palette import _close_palette
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.show_command_palette = True
|
||||
mock_app._command_palette_query = "save"
|
||||
mock_app._command_palette_selected = 2
|
||||
mock_app._command_palette_focused = True
|
||||
mock_app._command_palette_input_focused = True
|
||||
|
||||
_close_palette(mock_app)
|
||||
|
||||
assert mock_app.show_command_palette is False
|
||||
assert mock_app._command_palette_query == ""
|
||||
assert mock_app._command_palette_selected == 0
|
||||
assert mock_app._command_palette_focused is False
|
||||
assert mock_app._command_palette_input_focused is False
|
||||
|
||||
|
||||
def test_execute_runs_command_and_closes() -> None:
|
||||
"""_execute runs a command and closes the palette, catching exceptions. Pure unit test."""
|
||||
from src.command_palette import _execute, Command
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Build a mock app and a "good" command
|
||||
mock_app = MagicMock()
|
||||
mock_app.show_command_palette = True
|
||||
good_command = Command(
|
||||
id="test_good",
|
||||
title="Test Good",
|
||||
category="test",
|
||||
action=lambda app: setattr(app, "ran", True),
|
||||
)
|
||||
_execute(mock_app, good_command)
|
||||
assert mock_app.ran is True
|
||||
assert mock_app.show_command_palette is False
|
||||
|
||||
# Build a "bad" command that raises
|
||||
bad_app = MagicMock()
|
||||
bad_app.show_command_palette = True
|
||||
bad_command = Command(
|
||||
id="test_bad",
|
||||
title="Test Bad",
|
||||
category="test",
|
||||
action=lambda app: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
# Should NOT raise
|
||||
_execute(bad_app, bad_command)
|
||||
# Palette should still close
|
||||
assert bad_app.show_command_palette is False
|
||||
|
||||
|
||||
def test_fuzzy_match_returns_top_n_for_navigation() -> None:
|
||||
"""The palette returns up to top_n results so Up/Down navigation is meaningful."""
|
||||
from src.commands import registry
|
||||
all_commands = registry.all()
|
||||
# We have 11 commands; fuzzy_match with empty query returns all
|
||||
# Verify there are enough commands to navigate through with Up/Down
|
||||
assert len(all_commands) >= 3, "Need at least 3 commands to test navigation"
|
||||
|
||||
Reference in New Issue
Block a user