From d7449ae417ba4d15b51475b7fc785a9d47632d2e Mon Sep 17 00:00:00 2001 From: Ed_ Date: Tue, 2 Jun 2026 22:41:59 -0400 Subject: [PATCH] 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 --- src/command_palette.py | 55 +++++++++++++++++++-------- tests/test_command_palette_sim.py | 63 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/src/command_palette.py b/src/command_palette.py index 655b3940..1785e6c8 100644 --- a/src/command_palette.py +++ b/src/command_palette.py @@ -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() diff --git a/tests/test_command_palette_sim.py b/tests/test_command_palette_sim.py index 553ccfd6..b280aa1d 100644 --- a/tests/test_command_palette_sim.py +++ b/tests/test_command_palette_sim.py @@ -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"