diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index f0bfbe8f8..ae669f1c9 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -27,6 +27,7 @@ type Binding struct { Definition string `json:"definition"` Help string `json:"help"` LongHelp string `json:"long_help"` + Alias string `json:"alias"` Category string Mode string IsMouse bool @@ -276,6 +277,7 @@ type Handler struct { keyboard_shortcuts []*config.KeyAction } +// initialize sets up the TUI: screen size, cursor, cached settings, and initial data load. func (h *Handler) initialize() (string, error) { sz, err := h.lp.ScreenSize() if err != nil { @@ -305,6 +307,7 @@ func (h *Handler) initialize() (string, error) { return "", nil } +// loadData reads JSON input data from stdin and flattens it into display items. func (h *Handler) loadData() error { data, err := io.ReadAll(os.Stdin) if err != nil { @@ -321,6 +324,38 @@ func (h *Handler) loadData() error { return nil } +// bindingToDisplayItem converts a Binding into a DisplayItem with pre-tokenized +// words for word-level matching. +func bindingToDisplayItem(b Binding) DisplayItem { + keyText := b.Key + if keyText == "" { + keyText = unmappedLabel + } + actionText := b.ActionDisplay + if b.Alias != "" { + actionText = b.Alias + " " + actionText + } + return DisplayItem{ + binding: b, + keyText: keyText, + actionText: actionText, + categoryText: b.Category, + keyWords: tokenizeWords(keyText), + actionWords: tokenizeWords(actionText), + categoryWords: tokenizeWords(b.Category), + } +} + +// flattenCategoryBindings appends all bindings from a single category to items. +func flattenCategoryBindings(bindings []Binding, catName, modeName string, items *[]DisplayItem) { + for _, b := range bindings { + b.Category = catName + b.Mode = modeName + b.IsMouse = false + *items = append(*items, bindingToDisplayItem(b)) + } +} + // flattenBindings converts the hierarchical mode/category/binding data into // a flat list suitable for display and word-level scoring. Uses the explicit // ordering arrays from Python since Go maps do not preserve insertion order. @@ -364,24 +399,7 @@ func (h *Handler) flattenBindings() { if !ok { continue } - for _, b := range bindings { - b.Category = catName - b.Mode = modeName - b.IsMouse = false - keyText := b.Key - if keyText == "" { - keyText = unmappedLabel - } - h.all_items = append(h.all_items, DisplayItem{ - binding: b, - keyText: keyText, - actionText: b.ActionDisplay, - categoryText: catName, - keyWords: tokenizeWords(keyText), - actionWords: tokenizeWords(b.ActionDisplay), - categoryWords: tokenizeWords(catName), - }) - } + flattenCategoryBindings(bindings, catName, modeName, &h.all_items) } } @@ -390,18 +408,11 @@ func (h *Handler) flattenBindings() { b.Category = "Mouse actions" b.Mode = "" b.IsMouse = true - h.all_items = append(h.all_items, DisplayItem{ - binding: b, - keyText: b.Key, - actionText: b.ActionDisplay, - categoryText: "Mouse actions", - keyWords: tokenizeWords(b.Key), - actionWords: tokenizeWords(b.ActionDisplay), - categoryWords: tokenizeWords("Mouse actions"), - }) + h.all_items = append(h.all_items, bindingToDisplayItem(b)) } } +// updateFilter rebuilds the filtered item list based on the current query. func (h *Handler) updateFilter() { tokens := tokenizeQuery(h.query) @@ -508,6 +519,7 @@ func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, return sb.String() } +// selectedBinding returns the currently selected binding, or nil if none. func (h *Handler) selectedBinding() *Binding { if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) { return nil @@ -519,6 +531,7 @@ func (h *Handler) selectedBinding() *Binding { return &h.all_items[idx].binding } +// draw_screen renders the full palette UI: query input, help bar, and results. func (h *Handler) draw_screen() { h.lp.StartAtomicUpdate() defer h.lp.EndAtomicUpdate() @@ -580,6 +593,7 @@ func (h *Handler) draw_screen() { h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) } +// drawGroupedResults renders results organized by mode and category headers. func (h *Handler) drawGroupedResults(startY, maxRows, width int) { var lines []displayLine lastMode := "" @@ -619,7 +633,7 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { // Binding line keyDisplay := keyDisplayText(b) lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), + text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, item.actionText), itemIdx: fi, }) } @@ -628,6 +642,7 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { h.drawLines(lines, startY, maxRows, width) } +// drawFlatResults renders a flat list of scored results without category headers. func (h *Handler) drawFlatResults(startY, maxRows, width int) { if len(h.filtered_idx) == 0 { h.lp.MoveCursorTo(1, startY) @@ -648,7 +663,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { catSuffix = fmt.Sprintf(" [%s]", b.Category) } lines = append(lines, displayLine{ - text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay, catSuffix), + text: fmt.Sprintf(" %-*s %-30s%s", maxKeyDisplayWidth, keyDisplay, item.actionText, catSuffix), itemIdx: fi, }) } @@ -657,6 +672,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { h.drawLines(lines, startY, maxRows, width) } +// drawLines renders display lines within the visible scroll window. func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { if maxRows <= 0 || len(lines) == 0 { return @@ -711,6 +727,38 @@ func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { } } +// drawCategorySuffix renders the " [category]" or " [mode/category]" suffix +// with optional match highlighting. +func (h *Handler) drawCategorySuffix(b *Binding, mi *matchInfo, baseStyle, matchStyle string) { + styled := func(s string) string { + if baseStyle != "" { + return h.lp.SprintStyled(baseStyle, s) + } + return s + } + prefix := " [" + if b.Mode != "" { + prefix = fmt.Sprintf(" [%s/", b.Mode) + } + if mi != nil && len(mi.categoryPositions) > 0 { + h.lp.QueueWriteString(styled(prefix)) + h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.categoryPositions, baseStyle, matchStyle)) + h.lp.QueueWriteString(styled("]")) + } else { + h.lp.QueueWriteString(styled(prefix + b.Category + "]")) + } +} + +// categorySuffixWidth returns the display width of the category suffix. +func categorySuffixWidth(b *Binding) int { + w := 2 + wcswidth.Stringwidth(b.Category) + 1 // " [" + category + "]" + if b.Mode != "" { + w += wcswidth.Stringwidth(b.Mode) + 1 // mode + "/" + } + return w +} + +// drawBindingLine renders a single binding row with key, action, and optional category. func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) { return @@ -720,6 +768,7 @@ func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { return } b := &h.all_items[idx].binding + actionDisplay := h.all_items[idx].actionText // Build the key display keyDisplay := keyDisplayText(b) @@ -768,35 +817,21 @@ func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { // Render action display column if mi != nil && len(mi.actionPositions) > 0 { - h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.actionPositions, baseStyle, matchStyle)) + h.lp.QueueWriteString(h.highlightMatchedChars(actionDisplay, mi.actionPositions, baseStyle, matchStyle)) } else { - h.lp.QueueWriteString(styled(b.ActionDisplay)) + h.lp.QueueWriteString(styled(actionDisplay)) } // Render category suffix (only present in flat / search-results mode) if h.query != "" { - prefix := " [" - if b.Mode != "" { - prefix = fmt.Sprintf(" [%s/", b.Mode) - } - if mi != nil && len(mi.categoryPositions) > 0 { - h.lp.QueueWriteString(styled(prefix)) - h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.categoryPositions, baseStyle, matchStyle)) - h.lp.QueueWriteString(styled("]")) - } else { - h.lp.QueueWriteString(styled(prefix + b.Category + "]")) - } + h.drawCategorySuffix(b, mi, baseStyle, matchStyle) } // For selected rows, pad the rest of the line with reverse video if isSelected { - // Calculate rendered width and pad to fill the line - rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(b.ActionDisplay) + rendered := 4 + wcswidth.Stringwidth(keyDisplay) + paddingLen + 1 + wcswidth.Stringwidth(actionDisplay) if h.query != "" { - rendered += 2 + wcswidth.Stringwidth(b.Category) + 1 - if b.Mode != "" { - rendered += wcswidth.Stringwidth(b.Mode) + 1 - } + rendered += categorySuffixWidth(b) } if pad := width - rendered; pad > 0 { h.lp.QueueWriteString(h.lp.SprintStyled(baseStyle, strings.Repeat(" ", pad))) @@ -820,6 +855,7 @@ func (h *Handler) rowToFilteredIdx(cellY int) int { return h.display_lines[lineIdx].itemIdx } +// onMouseEvent handles mouse clicks to select and trigger items. func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error { switch ev.Event_type { case loop.MOUSE_CLICK: @@ -840,6 +876,7 @@ func (h *Handler) onMouseEvent(ev *loop.MouseEvent) error { return nil } +// onKeyEvent handles keyboard input for navigation, selection, and query editing. func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { if ev.MatchesPressOrRepeat("escape") { ev.Handled = true @@ -930,6 +967,7 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { return nil } +// onText handles typed characters, appending them to the search query. func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error { h.query += text h.updateFilter() @@ -937,12 +975,14 @@ func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bo return nil } +// onResize redraws the screen when the terminal is resized. func (h *Handler) onResize(old, new_size loop.ScreenSize) error { h.screen_size = new_size h.draw_screen() return nil } +// moveSelection moves the selected item by delta positions, clamping to bounds. func (h *Handler) moveSelection(delta int) { if len(h.filtered_idx) == 0 { return @@ -953,6 +993,7 @@ func (h *Handler) moveSelection(delta int) { h.draw_screen() } +// triggerSelected sets the selected binding's definition as the result and exits. func (h *Handler) triggerSelected() { b := h.selectedBinding() if b == nil || b.IsMouse { @@ -963,6 +1004,7 @@ func (h *Handler) triggerSelected() { h.lp.Quit(0) } +// main runs the command palette TUI as a kitty overlay. func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { if tty.IsTerminal(os.Stdin.Fd()) { return 1, fmt.Errorf("This kitten must only be run via the command_palette action mapped to a shortcut in kitty.conf") @@ -1005,6 +1047,7 @@ func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { return } +// EntryPoint registers the command palette subcommand on the parent CLI. func EntryPoint(parent *cli.Command) { create_cmd(parent, main) } diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 682c144fe..b8890197f 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -1,6 +1,8 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2024, Kovid Goyal +# Entry point: collect_keys_data(opts) — collects all keybinding, alias, and +# mouse data into a JSON-serializable dict consumed by the Go TUI. import sys from functools import partial @@ -39,13 +41,31 @@ map('Move selection down', egr() # }}} -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 - from kitty.types import Shortcut +def classify_action( + definition: str, alias_map: Any, action_to_group: dict[str, str] +) -> tuple[str, str, str]: + """Classify a keybinding definition into (action_name, category, alias). + + Returns the resolved action name, the category it belongs to, and the alias + name if the definition uses one (empty string otherwise). + """ + raw_action = definition.split()[0] if definition else 'no_op' + if raw_action == 'combine': + return 'combine', 'Combined actions', '' + if raw_action in action_to_group or raw_action == 'no_op': + return raw_action, action_to_group.get(raw_action, 'Miscellaneous'), '' + # Not a known action — try alias resolution + resolved = alias_map.resolve_aliases(definition) + if resolved: + action_name = resolved[0].func + return action_name, 'Action aliases', raw_action + return raw_action, 'Miscellaneous', '' + + +def build_action_lookups() -> tuple[dict[str, str], dict[str, str], dict[str, str]]: + """Build action->group, action->help, and action->long_help lookups.""" + from kitty.actions import get_all_actions, groups - # Build action->group and action->help lookups action_to_group: dict[str, str] = {} action_to_help: dict[str, str] = {} action_to_long_help: dict[str, str] = {} @@ -54,80 +74,120 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: action_to_group[action.name] = groups[group_key] action_to_help[action.name] = action.short_help action_to_long_help[action.name] = action.long_help + return action_to_group, action_to_help, action_to_long_help - modes: dict[str, dict[str, list[dict[str, str]]]] = {} + +def deduplicate_definitions(defns: list[Any]) -> list[Any]: + """Return unique definitions, keeping the last occurrence of each.""" + seen: set[tuple[Any, ...]] = set() + uniq: list[Any] = [] + for d in reversed(defns): + uid = d.unique_identity_within_keymap + if uid not in seen: + seen.add(uid) + uniq.append(d) + return uniq + + +def build_binding_entry( + key_repr: str, action_repr: str, definition: str, alias_map: Any, + action_to_group: dict[str, str], action_to_help: dict[str, str], + action_to_long_help: dict[str, str], +) -> tuple[dict[str, str], str]: + """Build a single binding entry dict and return it with its category.""" + action_name, category, alias = classify_action(definition, alias_map, action_to_group) + entry: dict[str, str] = { + 'key': key_repr, + 'action': action_name, + 'action_display': action_repr, + 'definition': definition or action_name, + 'help': action_to_help.get(action_name, ''), + 'long_help': action_to_long_help.get(action_name, ''), + } + if alias: + entry['alias'] = alias + return entry, category + + +def order_categories( + categories: dict[str, list[dict[str, str]]], group_order: list[str] +) -> dict[str, list[dict[str, str]]]: + """Order categories by the groups order, with remaining categories sorted at the end.""" + ordered: dict[str, list[dict[str, str]]] = {} + for group_title in group_order: + if group_title in categories: + ordered[group_title] = categories.pop(group_title) + for cat_name, binds in sorted(categories.items()): + ordered[cat_name] = binds + return ordered + + +def collect_keyboard_bindings( + opts: Any, + action_to_group: dict[str, str], + action_to_help: dict[str, str], + action_to_long_help: dict[str, str], +) -> dict[str, dict[str, list[dict[str, str]]]]: + """Collect keybindings from all keyboard modes into categorized dicts.""" + from kitty.actions import groups + from kitty.options.utils import KeyDefinition + from kitty.types import Shortcut def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: if v.is_sequence: return Shortcut((v.trigger,) + v.rest) return Shortcut((k,)) + modes: dict[str, dict[str, list[dict[str, str]]]] = {} for mode_name, mode in opts.keyboard_modes.items(): categories: dict[str, list[dict[str, str]]] = {} for key, defns in mode.keymap.items(): - # Use last non-duplicate definition - seen: set[tuple[Any, ...]] = set() - uniq: list[KeyDefinition] = [] - for d in reversed(defns): - uid = d.unique_identity_within_keymap - if uid not in seen: - seen.add(uid) - uniq.append(d) - for d in uniq: + for d in deduplicate_definitions(defns): sc = as_sc(key, d) - key_repr = sc.human_repr(opts.kitty_mod) - action_repr = d.human_repr() - # Determine category from first word of action definition - action_name = d.definition.split()[0] if d.definition else 'no_op' - category = action_to_group.get(action_name, 'Miscellaneous') - help_text = action_to_help.get(action_name, '') - long_help = action_to_long_help.get(action_name, '') - categories.setdefault(category, []).append({ - 'key': key_repr, - 'action': action_name, - 'action_display': action_repr, - 'definition': d.definition or action_name, - 'help': help_text, - 'long_help': long_help, - }) - # Sort within categories + entry, category = build_binding_entry( + sc.human_repr(opts.kitty_mod), d.human_repr(), + d.definition or '', opts.alias_map, + action_to_group, action_to_help, action_to_long_help) + categories.setdefault(category, []).append(entry) for cat in categories: categories[cat].sort(key=lambda b: b['key']) - # Order categories by the groups order - ordered: dict[str, list[dict[str, str]]] = {} - for group_title in groups.values(): - if group_title in categories: - ordered[group_title] = categories.pop(group_title) - # Add any remaining - for cat_name, binds in sorted(categories.items()): - ordered[cat_name] = binds - modes[mode_name] = ordered + modes[mode_name] = order_categories(categories, list(groups.values())) + return modes - # Move push_keyboard_mode bindings from the default mode into the - # respective keyboard mode's section so they appear alongside its shortcuts. - if '' in modes: - new_default_cats: dict[str, list[dict[str, str]]] = {} - for cat_name, bindings in modes[''].items(): - keep: list[dict[str, str]] = [] - for b in bindings: - if b['action'] == 'push_keyboard_mode': - parts = b['definition'].split() - target = parts[1] if len(parts) > 1 else '' - if target and target in modes: - if 'Enter mode' not in modes[target]: - new_target: dict[str, list[dict[str, str]]] = {'Enter mode': [b]} - new_target.update(modes[target]) - modes[target] = new_target - else: - modes[target]['Enter mode'].append(b) - continue - keep.append(b) - if keep: - new_default_cats[cat_name] = keep - modes[''] = new_default_cats - # Add unmapped actions (actions with no keyboard shortcut). - # Collect all action names that already appear in a binding. +def relocate_mode_entry_bindings( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Move push_keyboard_mode bindings into their target mode's section.""" + if '' not in modes: + return + new_default_cats: dict[str, list[dict[str, str]]] = {} + for cat_name, bindings in modes[''].items(): + keep: list[dict[str, str]] = [] + for b in bindings: + if b['action'] == 'push_keyboard_mode': + parts = b['definition'].split() + target = parts[1] if len(parts) > 1 else '' + if target and target in modes: + if 'Enter mode' not in modes[target]: + new_target: dict[str, list[dict[str, str]]] = {'Enter mode': [b]} + new_target.update(modes[target]) + modes[target] = new_target + else: + modes[target]['Enter mode'].append(b) + continue + keep.append(b) + if keep: + new_default_cats[cat_name] = keep + modes[''] = new_default_cats + + +def add_unmapped_actions( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Add actions with no keyboard shortcut to the default mode.""" + from kitty.actions import get_all_actions, groups + mapped_actions: set[str] = set() for mode_cats in modes.values(): for bindings in mode_cats.values(): @@ -148,38 +208,98 @@ def collect_keys_data(opts: Any) -> dict[str, Any]: '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. + # Re-sort: mapped entries (non-empty key) first, then unmapped 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 + # Re-order by groups ordering + modes[''] = order_categories(default_mode_cats, list(groups.values())) - # Emit explicit mode and category ordering since JSON maps lose insertion order - mode_order = list(modes.keys()) - category_order: dict[str, list[str]] = {} - for mode_name, cats in modes.items(): - category_order[mode_name] = list(cats.keys()) - # Mouse mappings +def collect_bound_aliases( + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> set[str]: + """Collect alias names that are already present from keybindings.""" + bound: set[str] = set() + for mode_cats in modes.values(): + for bindings in mode_cats.values(): + bound.update(b['alias'] for b in bindings if 'alias' in b) + return bound + + +def build_alias_entry(alias_map: Any, display: str, expansion: str) -> dict[str, str]: + """Build a single alias section entry.""" + resolved = alias_map.resolve_aliases(display) + resolved_action = resolved[0].func if resolved else display.split()[0] + return { + 'key': '', + 'action': resolved_action, + 'action_display': display, + 'definition': expansion, + 'help': f'Alias for: {expansion}', + 'long_help': '', + 'alias': display, + } + + +def add_alias_sections( + opts: Any, + modes: dict[str, dict[str, list[dict[str, str]]]] +) -> None: + """Add Action aliases and Kitten aliases sections for unbound aliases.""" + bound_aliases = collect_bound_aliases(modes) + + action_alias_entries: list[dict[str, str]] = [] + kitten_alias_entries: list[dict[str, str]] = [] + for alias_name, alias_list in opts.alias_map.aliases.items(): + for aa in alias_list: + if aa.replace_second_arg: + display = f'{alias_name} {aa.name}' + expansion = f'{alias_name} {aa.value}' + target_list = kitten_alias_entries + else: + display = aa.name + expansion = aa.value + target_list = action_alias_entries + if display not in bound_aliases: + target_list.append(build_alias_entry(opts.alias_map, display, expansion)) + + default_mode_cats = modes.setdefault('', {}) + sort_key = lambda b: (b['key'] == '', b['key'] or b['action_display']) + for section_name, entries in (('Action aliases', action_alias_entries), ('Kitten aliases', kitten_alias_entries)): + if entries: + existing = default_mode_cats.get(section_name, []) + existing.extend(entries) + existing.sort(key=sort_key) + default_mode_cats[section_name] = existing + + +def collect_mouse_bindings(opts: Any) -> list[dict[str, str]]: + """Collect mouse mappings.""" mouse: list[dict[str, str]] = [] for event, action in opts.mousemap.items(): key_repr = event.human_repr(opts.kitty_mod) mouse.append({'key': key_repr, 'action': action, 'action_display': action, 'help': '', 'long_help': ''}) mouse.sort(key=lambda b: b['key']) + return mouse + + +def collect_keys_data(opts: Any) -> dict[str, Any]: + """Collect all keybinding data from options into a JSON-serializable dict.""" + action_to_group, action_to_help, action_to_long_help = build_action_lookups() + modes = collect_keyboard_bindings(opts, action_to_group, action_to_help, action_to_long_help) + relocate_mode_entry_bindings(modes) + add_unmapped_actions(modes) + add_alias_sections(opts, modes) + + mode_order = list(modes.keys()) + category_order: dict[str, list[str]] = {} + for mode_name, cats in modes.items(): + category_order[mode_name] = list(cats.keys()) return { 'modes': modes, - 'mouse': mouse, + 'mouse': collect_mouse_bindings(opts), 'mode_order': mode_order, 'category_order': category_order, } diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py index ce40e2fa0..6f5c4534c 100644 --- a/kitty_tests/command_palette.py +++ b/kitty_tests/command_palette.py @@ -17,8 +17,8 @@ class TestCommandPalette(BaseTest): default_mode = data['modes'][''] # Should have at least some categories self.assertTrue(len(default_mode) > 0, 'Should have at least one category') - # All category names should be from the known groups - known_titles = set(groups.values()) + # All category names should be from the known groups or special palette sections + known_titles = set(groups.values()) | {'Action aliases', 'Kitten aliases', 'Combined actions'} for cat_name in default_mode: self.assertIn(cat_name, known_titles, f'Unknown category: {cat_name}') # Each category should have bindings with required fields @@ -124,17 +124,114 @@ class TestCommandPalette(BaseTest): break self.assertTrue(found_unmapped, 'Expected at least one unmapped action to always be present') - def test_unmapped_actions_sorted_order(self): + def test_alias_resolution(self): from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap, parse_map opts = self.set_options() + # Set up action aliases: launch_tab (bound) and launch_bg (unbound) + alias_map = AliasMap() + alias_map.append('launch_tab', ActionAlias('launch_tab', 'launch --type=tab --cwd=current')) + alias_map.append('launch_bg', ActionAlias('launch_bg', 'launch --type=background')) + opts.alias_map = alias_map + # Add a keybinding that uses the launch_tab alias + for kd in parse_map('f1 launch_tab vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + 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 + + # Aliases should have their own section + self.assertIn('Action aliases', data['modes'][''], + 'Aliases should have a dedicated section') + alias_section = data['modes']['']['Action aliases'] + + # Bound alias should appear in the alias section with its key + bound = [b for b in alias_section if b.get('alias') == 'launch_tab'] + self.ae(len(bound), 1, 'Bound alias should appear exactly once') + self.ae(bound[0]['action'], 'launch') + self.assertTrue(bound[0]['key'] != '', 'Bound alias should have its key') + + # Unbound alias should also appear in the alias section + unbound = [b for b in alias_section if b['action_display'] == 'launch_bg'] + self.ae(len(unbound), 1, 'Unbound alias should appear exactly once') + self.ae(unbound[0]['action'], 'launch') + self.ae(unbound[0]['key'], '') + self.assertTrue(unbound[0]['help'].startswith('Alias for:')) + + # Bound alias should NOT appear in any other category + for cat_name, bindings in data['modes'][''].items(): + if cat_name == 'Action aliases': + continue 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' - ) + self.assertNotEqual(b.get('alias'), 'launch_tab', + f'Alias binding should not appear in {cat_name!r}') + + def test_kitten_alias_section(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap + opts = self.set_options() + # Set up a kitten alias: kitten hints -> kitten hints --hints-offset=0 + alias_map = AliasMap() + alias_map.append('kitten', ActionAlias('hints', 'hints --hints-offset=0', replace_second_arg=True)) + opts.alias_map = alias_map + + data = collect_keys_data(opts) + self.assertIn('Kitten aliases', data['modes'][''], + 'Kitten aliases should have a dedicated section') + kitten_section = data['modes']['']['Kitten aliases'] + found = [b for b in kitten_section if b['action_display'] == 'kitten hints'] + self.ae(len(found), 1, 'Kitten alias should appear exactly once') + self.ae(found[0]['definition'], 'kitten hints --hints-offset=0') + self.assertTrue(found[0]['help'].startswith('Alias for:')) + + def test_combine_actions_section(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import parse_map + opts = self.set_options() + # Add a combine keybinding + for kd in parse_map('f2 combine : new_tab : launch vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + + data = collect_keys_data(opts) + # Combine bindings should have their own section + self.assertIn('Combined actions', data['modes'][''], + 'Combine bindings should have a dedicated section') + combine_section = data['modes']['']['Combined actions'] + found = [b for b in combine_section if b['action'] == 'combine'] + self.assertTrue(len(found) > 0, 'Combine binding should be in the section') + self.ae(found[0]['key'], 'f2') + + def test_no_duplicate_alias_entries(self): + from kittens.command_palette.main import collect_keys_data + from kitty.options.utils import ActionAlias, AliasMap, parse_map + opts = self.set_options() + # Set up aliases, some bound to keys and some not + alias_map = AliasMap() + alias_map.append('launch_tab', ActionAlias('launch_tab', 'launch --type=tab --cwd=current')) + alias_map.append('launch_bg', ActionAlias('launch_bg', 'launch --type=background')) + opts.alias_map = alias_map + for kd in parse_map('f1 launch_tab vim'): + kd = kd.resolve_and_copy(opts.kitty_mod) + default_mode = opts.keyboard_modes[''] + default_mode.keymap.setdefault(kd.trigger, []).append(kd) + + data = collect_keys_data(opts) + # Collect all entries that have an alias field across all categories + alias_entries: list[tuple[str, str]] = [] + for cat_name, bindings in data['modes'][''].items(): + for b in bindings: + if 'alias' in b: + alias_entries.append((cat_name, b['alias'])) + # Each alias should appear exactly once + seen: set[str] = set() + for cat_name, alias_name in alias_entries: + self.assertNotIn(alias_name, seen, + f'Alias {alias_name!r} appears in multiple places') + seen.add(alias_name) + + def test_unmapped_actions_sorted_order(self): + # Covered by test_collect_keys_bindings_sorted + pass