diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index 218b5afb3..a0a8d3d07 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -15,6 +15,7 @@ import ( "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" + "github.com/kovidgoyal/kitty/tools/utils" "github.com/kovidgoyal/kitty/tools/wcswidth" ) @@ -83,6 +84,11 @@ func truncateToWidth(s string, maxWidth int) string { return string(runes) + "..." } +// CachedSettings holds persistent UI settings stored in command-palette.json. +type CachedSettings struct { + ShowUnmapped bool `json:"show_unmapped"` +} + type Handler struct { lp *loop.Loop screen_size loop.ScreenSize @@ -98,6 +104,8 @@ type Handler struct { display_lines []displayLine results_start_y int results_height int + show_unmapped bool + cv *utils.CachedValues[*CachedSettings] } func (h *Handler) initialize() (string, error) { @@ -111,6 +119,12 @@ func (h *Handler) initialize() (string, error) { h.lp.AllowLineWrapping(false) h.lp.SetWindowTitle("Command Palette") + // Initialize with ShowUnmapped: true as the default; Load() returns this + // default when no cache file exists yet. + h.cv = utils.NewCachedValues("command-palette", &CachedSettings{ShowUnmapped: true}) + settings := h.cv.Load() + h.show_unmapped = settings.ShowUnmapped + if err := h.loadData(); err != nil { return "", err } @@ -211,10 +225,13 @@ func (h *Handler) flattenBindings() { func (h *Handler) updateFilter() { if h.query == "" { - // Show all items in original order - h.filtered_idx = make([]int, len(h.all_items)) - for i := range h.all_items { - h.filtered_idx[i] = i + // Show all items in original order, respecting the show_unmapped toggle + h.filtered_idx = make([]int, 0, len(h.all_items)) + for i, item := range h.all_items { + if !h.show_unmapped && item.binding.Key == "" { + continue + } + h.filtered_idx = append(h.filtered_idx, i) } h.match_infos = nil h.selected_idx = 0 @@ -253,6 +270,9 @@ func (h *Handler) updateFilter() { } var matches []scored for i := range h.all_items { + if !h.show_unmapped && h.all_items[i].binding.Key == "" { + continue + } bestScore := uint(0) bestCol := 0 var bestPositions []int @@ -378,9 +398,14 @@ func (h *Handler) draw_screen() { // Draw key hints footer h.lp.MoveCursorTo(1, hintsY) + unmappedToggleLabel := "Show" + if h.show_unmapped { + unmappedToggleLabel = "Hide" + } footer := h.lp.SprintStyled("fg=bright-yellow", "[Enter]") + " Run " + h.lp.SprintStyled("fg=bright-yellow", "[Esc]") + " Quit " + - h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate" + h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate " + + h.lp.SprintStyled("fg=bright-yellow", "[F12]") + " " + unmappedToggleLabel + " unmapped" matchInfo := "" if h.query != "" { matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) @@ -716,6 +741,17 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { } return nil } + if ev.MatchesPressOrRepeat("f12") { + ev.Handled = true + h.show_unmapped = !h.show_unmapped + if h.cv != nil { + h.cv.Opts.ShowUnmapped = h.show_unmapped + h.cv.Save() + } + h.updateFilter() + h.draw_screen() + return nil + } return nil } diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 4d9b418fa..806deb0dc 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -10,7 +10,7 @@ from ..tui.handler import result_handler -def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]: +def collect_keys_data(opts: Any) -> dict[str, Any]: """Collect all keybinding data from options into a JSON-serializable dict.""" from kitty.actions import get_all_actions, groups from kitty.options.utils import KeyDefinition @@ -97,33 +97,43 @@ def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: new_default_cats[cat_name] = keep modes[''] = new_default_cats - # Optionally add unmapped actions (actions with no keyboard shortcut). - if show_unmapped: - # Collect all action names that already appear in a binding. - mapped_actions: set[str] = set() - for mode_cats in modes.values(): - for bindings in mode_cats.values(): - for b in bindings: - mapped_actions.add(b['action']) + # Add unmapped actions (actions with no keyboard shortcut). + # Collect all action names that already appear in a binding. + mapped_actions: set[str] = set() + for mode_cats in modes.values(): + for bindings in mode_cats.values(): + for b in bindings: + mapped_actions.add(b['action']) - default_mode_cats = modes.setdefault('', {}) - for group_key, actions in get_all_actions().items(): - category = groups[group_key] - for action in actions: - if action.name not in mapped_actions: - default_mode_cats.setdefault(category, []).append({ - 'key': '', - 'action': action.name, - 'action_display': action.name, - 'definition': action.name, - 'help': action.short_help, - 'long_help': action.long_help, - }) + default_mode_cats = modes.setdefault('', {}) + for group_key, actions in get_all_actions().items(): + category = groups[group_key] + for action in actions: + if action.name not in mapped_actions: + default_mode_cats.setdefault(category, []).append({ + 'key': '', + 'action': action.name, + 'action_display': action.name, + 'definition': action.name, + 'help': action.short_help, + 'long_help': action.long_help, + }) - # Re-sort each category: mapped entries (non-empty key) by key first, - # then unmapped entries (empty key) sorted by action name. - for cat in default_mode_cats: - default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action'])) + # Re-sort each category: mapped entries (non-empty key) by key first, + # then unmapped entries (empty key) sorted by action name. + for cat in default_mode_cats: + default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action'])) + + # Re-order default_mode_cats by groups ordering (adding unmapped actions may + # have appended new categories at the end, breaking the established order). + reordered: dict[str, list[dict[str, str]]] = {} + for group_title in groups.values(): + if group_title in default_mode_cats: + reordered[group_title] = default_mode_cats[group_title] + for cat_name, binds in default_mode_cats.items(): + if cat_name not in reordered: + reordered[cat_name] = binds + modes[''] = reordered # Emit explicit mode and category ordering since JSON maps lose insertion order mode_order = list(modes.keys()) @@ -160,9 +170,6 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, help_text = 'Browse and trigger keyboard shortcuts and actions' usage = '' OPTIONS = r''' ---show-unmapped -type=bool-set -Also show actions that have not been mapped to a keyboard shortcut. '''.format diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go index 41ff880f3..b30a46f04 100644 --- a/kittens/command_palette/main_test.go +++ b/kittens/command_palette/main_test.go @@ -130,10 +130,11 @@ func TestMouseBindingsMarkedCorrectly(t *testing.T) { func TestFilterNoQueryReturnsAll(t *testing.T) { h := newTestHandler() + h.show_unmapped = true // show all items including unmapped h.query = "" h.updateFilter() if len(h.filtered_idx) != len(h.all_items) { - t.Fatalf("With no query, expected %d items, got %d", len(h.all_items), len(h.filtered_idx)) + t.Fatalf("With no query and show_unmapped=true, expected %d items, got %d", len(h.all_items), len(h.filtered_idx)) } for i, idx := range h.filtered_idx { if idx != i { @@ -670,10 +671,77 @@ func TestUnmappedActionDisplayed(t *testing.T) { if unmapped.colTexts[0] != unmappedLabel { t.Fatalf("Expected colTexts[0]=%q for unmapped item, got %q", unmappedLabel, unmapped.colTexts[0]) } - // Should be searchable by action name + + // With show_unmapped=true, unmapped action should be searchable + h.show_unmapped = true h.query = "scroll_home" h.updateFilter() if len(h.filtered_idx) == 0 { - t.Fatal("Expected unmapped action to be found by action name search") + t.Fatal("Expected unmapped action to be found by action name search when show_unmapped=true") + } + + // With show_unmapped=false, unmapped action should be hidden + h.show_unmapped = false + h.query = "" + h.updateFilter() + for _, idx := range h.filtered_idx { + if h.all_items[idx].binding.Key == "" { + t.Fatal("Expected unmapped action to be hidden when show_unmapped=false") + } + } +} + +func TestShowUnmappedToggle(t *testing.T) { + // TestShowUnmappedToggle creates a handler with both mapped and unmapped items + // and verifies that the show_unmapped flag correctly filters the display. + h := &Handler{} + mixedJSON := `{ + "modes": { + "": { + "Copy/paste": [ + {"key": "ctrl+c", "action": "copy", "action_display": "copy", "definition": "copy", "help": "Copy", "long_help": ""}, + {"key": "", "action": "paste_from_buffer", "action_display": "paste_from_buffer", "definition": "paste_from_buffer", "help": "Paste from buffer", "long_help": ""} + ] + } + }, + "mouse": [], + "mode_order": [""], + "category_order": {"": ["Copy/paste"]} + }` + if err := json.Unmarshal([]byte(mixedJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + + if len(h.all_items) != 2 { + t.Fatalf("Expected 2 items in all_items, got %d", len(h.all_items)) + } + + // With show_unmapped=false, only mapped items should appear + h.show_unmapped = false + h.updateFilter() + if len(h.filtered_idx) != 1 { + t.Fatalf("With show_unmapped=false, expected 1 item, got %d", len(h.filtered_idx)) + } + if h.all_items[h.filtered_idx[0]].binding.Key == "" { + t.Fatal("Filtered item should not be unmapped when show_unmapped=false") + } + + // With show_unmapped=true, both items should appear + h.show_unmapped = true + h.updateFilter() + if len(h.filtered_idx) != 2 { + t.Fatalf("With show_unmapped=true, expected 2 items, got %d", len(h.filtered_idx)) + } + + // Toggle back to false with a query active; unmapped should still be hidden + h.show_unmapped = false + h.query = "paste" + h.updateFilter() + for _, idx := range h.filtered_idx { + if h.all_items[idx].binding.Key == "" { + t.Fatal("Unmapped item should not appear in search results when show_unmapped=false") + } } } diff --git a/kitty/boss.py b/kitty/boss.py index 754577099..4dea95323 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2304,18 +2304,10 @@ def input_unicode_character(self) -> None: @ac('misc', ''' Browse and trigger keyboard shortcuts and actions in a searchable overlay. - - Optionally pass :code:`show_unmapped` to also show actions that have no keyboard shortcut assigned:: - - # show only mapped actions (default) - map ctrl+shift+p command_palette - # also show unmapped actions - map ctrl+shift+alt+p command_palette show_unmapped ''') - def command_palette(self, *args: str) -> None: + def command_palette(self) -> None: from kittens.command_palette.main import collect_keys_data - show_unmapped = 'show_unmapped' in args - data = collect_keys_data(get_options(), show_unmapped=show_unmapped) + data = collect_keys_data(get_options()) self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch) @ac( diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py index 00bec7ee5..ce40e2fa0 100644 --- a/kitty_tests/command_palette.py +++ b/kitty_tests/command_palette.py @@ -33,7 +33,7 @@ def test_collect_keys_data(self): self.assertIn('long_help', b) self.assertIsInstance(b['key'], str) self.assertIsInstance(b['action'], str) - self.assertTrue(len(b['key']) > 0) + # key may be empty for unmapped actions; action must always be non-empty self.assertTrue(len(b['action']) > 0) # Mouse mappings self.assertIsInstance(data['mouse'], list) @@ -61,9 +61,17 @@ def test_collect_keys_bindings_sorted(self): from kittens.command_palette.main import collect_keys_data opts = self.set_options() data = collect_keys_data(opts) + # Within each category, mapped entries (non-empty key) come first sorted by key, + # then unmapped entries (empty key) sorted by action name. for cat_name, bindings in data['modes'][''].items(): - keys = [b['key'] for b in bindings] - self.ae(keys, sorted(keys), f'Bindings in {cat_name} should be sorted by key') + seen_unmapped = False + for b in bindings: + if b['key'] == '': + seen_unmapped = True + elif seen_unmapped: + self.fail( + f'In category {cat_name!r}, mapped binding {b!r} follows an unmapped one' + ) def test_collect_keys_has_help_text(self): from kittens.command_palette.main import collect_keys_data @@ -99,28 +107,13 @@ def test_ordering_arrays_present(self): f'category_order for mode {mode_name!r} should match modes keys', ) - def test_show_unmapped_includes_extra_actions(self): + def test_always_includes_unmapped_actions(self): from kittens.command_palette.main import collect_keys_data - from kitty.actions import get_all_actions opts = self.set_options() - data_default = collect_keys_data(opts, show_unmapped=False) - data_unmapped = collect_keys_data(opts, show_unmapped=True) - # With show_unmapped=True, we should have at least as many bindings - def count_bindings(data: dict) -> int: - total = 0 - for cats in data['modes'].values(): - for bindings in cats.values(): - total += len(bindings) - return total - count_default = count_bindings(data_default) - count_with_unmapped = count_bindings(data_unmapped) - self.assertTrue( - count_with_unmapped >= count_default, - 'show_unmapped should not remove any existing bindings', - ) - # There should be at least one unmapped action (empty key) in the result + data = collect_keys_data(opts) + # Unmapped actions (empty key) are always included found_unmapped = False - for cats in data_unmapped['modes'].values(): + for cats in data['modes'].values(): for bindings in cats.values(): for b in bindings: if b['key'] == '': @@ -129,24 +122,12 @@ def count_bindings(data: dict) -> int: self.assertTrue(len(b['action']) > 0) self.assertTrue(len(b['definition']) > 0) break - self.assertTrue(found_unmapped, 'Expected at least one unmapped action') + self.assertTrue(found_unmapped, 'Expected at least one unmapped action to always be present') - def test_show_unmapped_false_has_no_empty_keys(self): + def test_unmapped_actions_sorted_order(self): from kittens.command_palette.main import collect_keys_data opts = self.set_options() - data = collect_keys_data(opts, show_unmapped=False) - for cats in data['modes'].values(): - for bindings in cats.values(): - for b in bindings: - self.assertTrue( - len(b['key']) > 0, - f'Without show_unmapped, all bindings should have non-empty keys; got {b!r}', - ) - - def test_show_unmapped_sorted_order(self): - from kittens.command_palette.main import collect_keys_data - opts = self.set_options() - data = collect_keys_data(opts, show_unmapped=True) + data = collect_keys_data(opts) # In each category, mapped bindings (non-empty key) should come before unmapped ones for cat_name, bindings in data['modes'].get('', {}).items(): seen_unmapped = False