diff --git a/kittens/command_palette/__init__.py b/kittens/command_palette/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go new file mode 100644 index 000000000..c9b1ac765 --- /dev/null +++ b/kittens/command_palette/main.go @@ -0,0 +1,614 @@ +// License: GPLv3 Copyright: 2024, Kovid Goyal + +package command_palette + +import ( + "encoding/json" + "fmt" + "io" + "os" + "sort" + "strings" + + "github.com/kovidgoyal/kitty/tools/cli" + "github.com/kovidgoyal/kitty/tools/fzf" + "github.com/kovidgoyal/kitty/tools/tui/loop" + "github.com/kovidgoyal/kitty/tools/utils" + "github.com/kovidgoyal/kitty/tools/wcswidth" +) + +var _ = fmt.Print + +// JSON data structures matching Python collect_keys_data output +type Binding struct { + Key string `json:"key"` + Action string `json:"action"` + ActionDisplay string `json:"action_display"` + Help string `json:"help"` + LongHelp string `json:"long_help"` + Category string + Mode string + IsMouse bool +} + +type InputData struct { + Modes map[string]map[string][]Binding `json:"modes"` + Mouse []Binding `json:"mouse"` + ModeOrder []string `json:"mode_order"` + CategoryOrder map[string][]string `json:"category_order"` +} + +// DisplayItem wraps a binding with its search text for FZF scoring +type DisplayItem struct { + binding Binding + searchText string // key + action_display + category for FZF +} + +type displayLine struct { + text string + isHeader bool + isModeHdr bool + itemIdx int // index into filtered_idx, -1 for headers +} + +type Handler struct { + lp *loop.Loop + screen_size loop.ScreenSize + all_items []DisplayItem + search_texts []string // parallel to all_items, for FZF scoring + matcher *fzf.FuzzyMatcher + filtered_idx []int // indices into all_items for current results + query string + selected_idx int + scroll_offset int + input_data InputData +} + +func (h *Handler) initialize() (string, error) { + sz, err := h.lp.ScreenSize() + if err != nil { + return "", err + } + h.screen_size = sz + h.lp.SetCursorVisible(true) + h.lp.SetCursorShape(loop.BAR_CURSOR, true) + h.lp.AllowLineWrapping(false) + h.lp.SetWindowTitle("Command Palette") + + if err := h.loadData(); err != nil { + return "", err + } + + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + h.updateFilter() + h.draw_screen() + h.lp.SendOverlayReady() + return "", nil +} + +func (h *Handler) loadData() error { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("no input data received on stdin; this kitten must be launched from kitty") + } + if err := json.Unmarshal(data, &h.input_data); err != nil { + return fmt.Errorf("failed to parse input data: %w", err) + } + + h.flattenBindings() + return nil +} + +// flattenBindings converts the hierarchical mode/category/binding data into +// a flat list suitable for display and FZF scoring. Uses the explicit ordering +// arrays from Python since Go maps do not preserve insertion order. +func (h *Handler) flattenBindings() { + // Use explicit mode ordering from Python, falling back to sorted keys + modeNames := h.input_data.ModeOrder + if len(modeNames) == 0 { + modeNames = make([]string, 0, len(h.input_data.Modes)) + for name := range h.input_data.Modes { + modeNames = append(modeNames, name) + } + sort.Slice(modeNames, func(i, j int) bool { + if modeNames[i] == "" { + return true + } + if modeNames[j] == "" { + return false + } + return modeNames[i] < modeNames[j] + }) + } + + for _, modeName := range modeNames { + categories, ok := h.input_data.Modes[modeName] + if !ok { + continue + } + + // Use explicit category ordering from Python, falling back to sorted keys + catNames := h.input_data.CategoryOrder[modeName] + if len(catNames) == 0 { + catNames = make([]string, 0, len(categories)) + for name := range categories { + catNames = append(catNames, name) + } + sort.Strings(catNames) + } + + for _, catName := range catNames { + bindings, ok := categories[catName] + if !ok { + continue + } + for _, b := range bindings { + b.Category = catName + b.Mode = modeName + b.IsMouse = false + searchText := b.Key + " " + b.ActionDisplay + " " + catName + if modeName != "" { + searchText += " " + modeName + } + h.all_items = append(h.all_items, DisplayItem{ + binding: b, + searchText: searchText, + }) + } + } + } + + // Mouse bindings + for _, b := range h.input_data.Mouse { + b.Category = "Mouse actions" + b.Mode = "" + b.IsMouse = true + searchText := b.Key + " " + b.ActionDisplay + " Mouse" + h.all_items = append(h.all_items, DisplayItem{ + binding: b, + searchText: searchText, + }) + } + + // Build parallel search texts array for FZF + h.search_texts = make([]string, len(h.all_items)) + for i, item := range h.all_items { + h.search_texts[i] = item.searchText + } +} + +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 + } + } else { + results, err := h.matcher.Score(h.search_texts, h.query) + if err != nil { + h.filtered_idx = nil + return + } + type scored struct { + idx int + score uint + } + var matches []scored + for i, r := range results { + if r.Score > 0 { + matches = append(matches, scored{idx: i, score: r.Score}) + } + } + sort.Slice(matches, func(i, j int) bool { + return matches[i].score > matches[j].score + }) + h.filtered_idx = make([]int, len(matches)) + for i, m := range matches { + h.filtered_idx[i] = m.idx + } + } + h.selected_idx = 0 + h.scroll_offset = 0 +} + +func (h *Handler) selectedBinding() *Binding { + if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) { + return nil + } + idx := h.filtered_idx[h.selected_idx] + if idx < 0 || idx >= len(h.all_items) { + return nil + } + return &h.all_items[idx].binding +} + +func (h *Handler) draw_screen() { + h.lp.StartAtomicUpdate() + defer h.lp.EndAtomicUpdate() + h.lp.ClearScreen() + + width := int(h.screen_size.WidthCells) + height := int(h.screen_size.HeightCells) + if width < 10 || height < 5 { + return + } + + // Layout: line 1 = search bar, lines 2..height-2 = results, + // line height-1 = help text, line height = key hints + searchBarY := 1 + resultsStartY := 2 + helpY := height - 1 + hintsY := height + resultsHeight := helpY - resultsStartY + if resultsHeight < 1 { + resultsHeight = 1 + } + + // Draw search bar + h.lp.MoveCursorTo(1, searchBarY) + h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-yellow", "> ")) + h.lp.QueueWriteString(h.query) + + // Draw results + if h.query == "" { + h.drawGroupedResults(resultsStartY, resultsHeight, width) + } else { + h.drawFlatResults(resultsStartY, resultsHeight, width) + } + + // Draw help text for selected binding + h.lp.MoveCursorTo(1, helpY) + if b := h.selectedBinding(); b != nil && b.Help != "" { + helpStr := b.Help + maxLen := width - 2 + if maxLen < 3 { + maxLen = 3 + } + if wcswidth.Stringwidth(helpStr) > maxLen { + // Truncate by runes to avoid breaking multi-byte characters + runes := []rune(helpStr) + for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxLen { + runes = runes[:len(runes)-1] + } + helpStr = string(runes) + "..." + } + h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr)) + } + + // Draw key hints footer + h.lp.MoveCursorTo(1, hintsY) + 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" + matchInfo := "" + if h.query != "" { + matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) + } + h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo)) + + // Position cursor at end of search text for typing + h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) +} + +func (h *Handler) drawGroupedResults(startY, maxRows, width int) { + var lines []displayLine + lastMode := "" + lastCategory := "" + + for fi, idx := range h.filtered_idx { + item := &h.all_items[idx] + b := &item.binding + + // Mode header when mode changes + if b.Mode != lastMode { + lastMode = b.Mode + lastCategory = "" + if b.Mode != "" { + if len(lines) > 0 { + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" Mode: %s", b.Mode), + isModeHdr: true, isHeader: true, itemIdx: -1, + }) + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + } + + // Category header when category changes + if b.Category != lastCategory { + lastCategory = b.Category + if len(lines) > 0 && !lines[len(lines)-1].isHeader { + lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) + } + catWidth := wcswidth.Stringwidth(b.Category) + sepLen := max(0, width-catWidth-6) + sep := strings.Repeat("\u2500", sepLen) + lines = append(lines, displayLine{ + text: fmt.Sprintf(" \u2500\u2500 %s %s", b.Category, sep), + isHeader: true, itemIdx: -1, + }) + } + + // Binding line + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" %-*s %s", keyPad, b.Key, b.ActionDisplay), + itemIdx: fi, + }) + } + + h.drawLines(lines, startY, maxRows, width) +} + +func (h *Handler) drawFlatResults(startY, maxRows, width int) { + var lines []displayLine + for fi, idx := range h.filtered_idx { + item := &h.all_items[idx] + b := &item.binding + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + catSuffix := "" + if b.Mode != "" { + catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category) + } else { + catSuffix = fmt.Sprintf(" [%s]", b.Category) + } + lines = append(lines, displayLine{ + text: fmt.Sprintf(" %-*s %-30s%s", keyPad, b.Key, b.ActionDisplay, catSuffix), + itemIdx: fi, + }) + } + + h.drawLines(lines, startY, maxRows, width) +} + +func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { + if maxRows <= 0 || len(lines) == 0 { + return + } + + // Adjust scroll to keep selected item visible + selectedLineIdx := -1 + for i, dl := range lines { + if dl.itemIdx == h.selected_idx { + selectedLineIdx = i + break + } + } + if selectedLineIdx >= 0 { + if selectedLineIdx < h.scroll_offset { + h.scroll_offset = selectedLineIdx + } + if selectedLineIdx >= h.scroll_offset+maxRows { + h.scroll_offset = selectedLineIdx - maxRows + 1 + } + } + h.scroll_offset = max(0, h.scroll_offset) + h.scroll_offset = min(h.scroll_offset, max(0, len(lines)-maxRows)) + + end := min(h.scroll_offset+maxRows, len(lines)) + for row, li := range lines[h.scroll_offset:end] { + h.lp.MoveCursorTo(1, startY+row) + text := li.text + // Truncate at rune boundary to avoid breaking multi-byte characters + if wcswidth.Stringwidth(text) > width { + runes := []rune(text) + for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > width { + runes = runes[:len(runes)-1] + } + text = string(runes) + } + + if li.isModeHdr { + h.lp.QueueWriteString(h.lp.SprintStyled("bold fg=magenta", text)) + } else if li.isHeader { + h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-blue", text)) + } else if li.itemIdx == h.selected_idx { + // Selected item: highlight with reverse video + padded := text + textWidth := wcswidth.Stringwidth(text) + if textWidth < width { + padded += strings.Repeat(" ", width-textWidth) + } + h.lp.QueueWriteString(h.lp.SprintStyled("fg=black bg=white", padded)) + } else { + h.drawBindingLine(text, li.itemIdx, width) + } + } +} + +func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { + if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) { + h.lp.QueueWriteString(text) + return + } + idx := h.filtered_idx[filteredIdx] + if idx < 0 || idx >= len(h.all_items) { + h.lp.QueueWriteString(text) + return + } + b := &h.all_items[idx].binding + + // Style the key portion green, leave action unstyled + keyWidth := wcswidth.Stringwidth(b.Key) + keyPad := 30 + if keyWidth > keyPad-4 { + keyPad = keyWidth + 6 + } + keyPrefix := fmt.Sprintf(" %-*s", keyPad, b.Key) + rest := "" + if len(text) > len(keyPrefix) { + rest = text[len(keyPrefix):] + } + h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix)) + h.lp.QueueWriteString(rest) +} + +func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error { + if ev.MatchesPressOrRepeat("escape") { + ev.Handled = true + if h.query != "" { + h.query = "" + h.updateFilter() + h.draw_screen() + } else { + h.lp.Quit(0) + } + return nil + } + if ev.MatchesPressOrRepeat("enter") { + ev.Handled = true + h.triggerSelected() + return nil + } + if ev.MatchesPressOrRepeat("up") || ev.MatchesPressOrRepeat("ctrl+k") || ev.MatchesPressOrRepeat("ctrl+p") { + ev.Handled = true + h.moveSelection(-1) + return nil + } + if ev.MatchesPressOrRepeat("down") || ev.MatchesPressOrRepeat("ctrl+j") || ev.MatchesPressOrRepeat("ctrl+n") { + ev.Handled = true + h.moveSelection(1) + return nil + } + if ev.MatchesPressOrRepeat("page_up") { + ev.Handled = true + delta := max(1, int(h.screen_size.HeightCells)-4) + h.moveSelection(-delta) + return nil + } + if ev.MatchesPressOrRepeat("page_down") { + ev.Handled = true + delta := max(1, int(h.screen_size.HeightCells)-4) + h.moveSelection(delta) + return nil + } + if ev.MatchesPressOrRepeat("home") || ev.MatchesPressOrRepeat("ctrl+home") { + ev.Handled = true + h.selected_idx = 0 + h.draw_screen() + return nil + } + if ev.MatchesPressOrRepeat("end") || ev.MatchesPressOrRepeat("ctrl+end") { + ev.Handled = true + if len(h.filtered_idx) > 0 { + h.selected_idx = len(h.filtered_idx) - 1 + } + h.draw_screen() + return nil + } + if ev.MatchesPressOrRepeat("backspace") { + ev.Handled = true + if h.query != "" { + g := wcswidth.SplitIntoGraphemes(h.query) + h.query = strings.Join(g[:len(g)-1], "") + h.updateFilter() + h.draw_screen() + } else { + h.lp.Beep() + } + return nil + } + return nil +} + +func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error { + h.query += text + h.updateFilter() + h.draw_screen() + return nil +} + +func (h *Handler) onResize(old, new_size loop.ScreenSize) error { + h.screen_size = new_size + h.draw_screen() + return nil +} + +func (h *Handler) moveSelection(delta int) { + if len(h.filtered_idx) == 0 { + return + } + h.selected_idx += delta + h.selected_idx = max(0, h.selected_idx) + h.selected_idx = min(h.selected_idx, len(h.filtered_idx)-1) + h.draw_screen() +} + +func (h *Handler) triggerSelected() { + b := h.selectedBinding() + if b == nil || b.IsMouse { + h.lp.Beep() + return + } + + // Send RC action command via DCS escape code. + // Do not set "self" or "match_window" — the action runs globally via + // the boss, same as if the user had pressed the keyboard shortcut. + payload := map[string]any{ + "action": b.ActionDisplay, + } + + rc := utils.RemoteControlCmd{ + Cmd: "action", + Version: [3]int{0, 26, 0}, + NoResponse: true, + } + rc.Payload = payload + + data, err := json.Marshal(rc) + if err != nil { + h.lp.Beep() + return + } + h.lp.QueueWriteString("\x1bP@kitty-cmd") + h.lp.QueueWriteString(string(data)) + h.lp.QueueWriteString("\x1b\\") + h.lp.Quit(0) +} + +func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) { + lp, err := loop.New() + if err != nil { + return 1, err + } + + handler := &Handler{lp: lp} + + lp.OnInitialize = func() (string, error) { + return handler.initialize() + } + lp.OnFinalize = func() string { return "" } + lp.OnKeyEvent = handler.onKeyEvent + lp.OnText = handler.onText + lp.OnResize = handler.onResize + + err = lp.Run() + if err != nil { + return 1, err + } + ds := lp.DeathSignalName() + if ds != "" { + fmt.Println("Killed by signal:", ds) + lp.KillIfSignalled() + return + } + rc = lp.ExitCode() + return +} + +func EntryPoint(parent *cli.Command) { + create_cmd(parent, main) +} diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py new file mode 100644 index 000000000..2abb3e95c --- /dev/null +++ b/kittens/command_palette/main.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + + +import sys +from typing import Any + +from kitty.typing_compat import BossType + +from ..tui.handler import result_handler + + +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 + + # 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] = {} + for group_key, actions in get_all_actions().items(): + for action in actions: + 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 + + modes: dict[str, dict[str, list[dict[str, str]]]] = {} + + def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut: + if v.is_sequence: + return Shortcut((v.trigger,) + v.rest) + return Shortcut((k,)) + + 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: + 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, + 'help': help_text, + 'long_help': long_help, + }) + # Sort within categories + 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 + + # 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 + 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 { + 'modes': modes, + 'mouse': mouse, + 'mode_order': mode_order, + 'category_order': category_order, + } + + +def main(args: list[str]) -> None: + raise SystemExit('This must be run as kitten command-palette') + + +main.allow_remote_control = True # type: ignore[attr-defined] +main.remote_control_password = True # type: ignore[attr-defined] + + +@result_handler(has_ready_notification=True) +def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None: + pass + + +help_text = 'Browse and trigger keyboard shortcuts and actions' +usage = '' +OPTIONS = ''.format + + +if __name__ == '__main__': + main(sys.argv) +elif __name__ == '__doc__': + cd = sys.cli_docs # type: ignore + cd['usage'] = usage + cd['options'] = OPTIONS + cd['help_text'] = help_text + cd['short_desc'] = help_text diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go new file mode 100644 index 000000000..e1bb8ffd1 --- /dev/null +++ b/kittens/command_palette/main_test.go @@ -0,0 +1,309 @@ +package command_palette + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/kovidgoyal/kitty/tools/fzf" +) + +func sampleInputJSON() string { + return `{ + "modes": { + "": { + "Copy/paste": [ + {"key": "ctrl+shift+c", "action": "copy_to_clipboard", "action_display": "copy_to_clipboard", "help": "Copy the selected text from the active window to the clipboard", "long_help": ""}, + {"key": "ctrl+shift+v", "action": "paste_from_clipboard", "action_display": "paste_from_clipboard", "help": "Paste from the clipboard to the active window", "long_help": ""} + ], + "Scrolling": [ + {"key": "ctrl+shift+up", "action": "scroll_line_up", "action_display": "scroll_line_up", "help": "Scroll up one line", "long_help": ""}, + {"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "help": "Scroll down one line", "long_help": ""} + ], + "Window management": [ + {"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "help": "Open a new window", "long_help": ""} + ] + }, + "mw": { + "Miscellaneous": [ + {"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""}, + {"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""} + ] + } + }, + "mouse": [ + {"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "help": "", "long_help": ""}, + {"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "help": "", "long_help": ""} + ], + "mode_order": ["", "mw"], + "category_order": { + "": ["Copy/paste", "Scrolling", "Window management"], + "mw": ["Miscellaneous"] + } + }` +} + +func newTestHandler() *Handler { + h := &Handler{} + if err := json.Unmarshal([]byte(sampleInputJSON()), &h.input_data); err != nil { + panic("test data JSON is invalid: " + err.Error()) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + return h +} + +func TestFlattenAllBindings(t *testing.T) { + h := newTestHandler() + // 5 default mode + 2 mw mode + 2 mouse = 9 + if len(h.all_items) != 9 { + t.Fatalf("Expected 9 items, got %d", len(h.all_items)) + } +} + +func TestDefaultModeComesFirst(t *testing.T) { + h := newTestHandler() + // First 5 items should be from default mode + for i := 0; i < 5; i++ { + if h.all_items[i].binding.Mode != "" { + t.Fatalf("Item %d should be from default mode, got mode=%q", i, h.all_items[i].binding.Mode) + } + } +} + +func TestCategoryOrderPreserved(t *testing.T) { + h := newTestHandler() + // Verify categories appear in the order specified by category_order + var categories []string + seen := map[string]bool{} + for _, item := range h.all_items { + if item.binding.Mode != "" || item.binding.IsMouse { + continue + } + cat := item.binding.Category + if !seen[cat] { + categories = append(categories, cat) + seen[cat] = true + } + } + expected := []string{"Copy/paste", "Scrolling", "Window management"} + if len(categories) != len(expected) { + t.Fatalf("Expected %d categories, got %d: %v", len(expected), len(categories), categories) + } + for i, cat := range categories { + if cat != expected[i] { + t.Fatalf("Category %d: expected %q, got %q", i, expected[i], cat) + } + } +} + +func TestCustomModePresent(t *testing.T) { + h := newTestHandler() + found := false + for _, item := range h.all_items { + if item.binding.Mode == "mw" { + found = true + break + } + } + if !found { + t.Fatal("Expected to find items from 'mw' mode") + } +} + +func TestMouseBindingsMarkedCorrectly(t *testing.T) { + h := newTestHandler() + mouseCount := 0 + for _, item := range h.all_items { + if item.binding.IsMouse { + mouseCount++ + if item.binding.Category != "Mouse actions" { + t.Fatalf("Mouse binding should have category 'Mouse actions', got %q", item.binding.Category) + } + } + } + if mouseCount != 2 { + t.Fatalf("Expected 2 mouse bindings, got %d", mouseCount) + } +} + +func TestFilterNoQueryReturnsAll(t *testing.T) { + h := newTestHandler() + 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)) + } + for i, idx := range h.filtered_idx { + if idx != i { + t.Fatalf("Expected sequential order, got index %d at position %d", idx, i) + } + } +} + +func TestFilterMatchesSubset(t *testing.T) { + h := newTestHandler() + h.query = "clipboard" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'clipboard'") + } + if len(h.filtered_idx) >= len(h.all_items) { + t.Fatal("Expected fewer matches than total items") + } + // Verify all returned items actually contain relevant text + for _, idx := range h.filtered_idx { + text := strings.ToLower(h.all_items[idx].searchText) + if !strings.Contains(text, "clipboard") { + // FZF does fuzzy matching, so this is a soft check — + // the characters should at least be present + } + } +} + +func TestFilterNonsenseReturnsEmpty(t *testing.T) { + h := newTestHandler() + h.query = "zzzznonexistent" + h.updateFilter() + if len(h.filtered_idx) != 0 { + t.Fatalf("Expected no matches for nonsense, got %d", len(h.filtered_idx)) + } +} + +func TestFilterResetsSelectionAndScroll(t *testing.T) { + h := newTestHandler() + h.query = "" + h.updateFilter() + h.selected_idx = 3 + h.scroll_offset = 5 + + h.query = "scroll" + h.updateFilter() + if h.selected_idx != 0 { + t.Fatalf("Expected selection reset to 0, got %d", h.selected_idx) + } + if h.scroll_offset != 0 { + t.Fatalf("Expected scroll offset reset to 0, got %d", h.scroll_offset) + } +} + +func TestSelectedBindingValid(t *testing.T) { + h := newTestHandler() + h.updateFilter() + + b := h.selectedBinding() + if b == nil { + t.Fatal("Expected non-nil binding") + } + if b.Key == "" || b.Action == "" { + t.Fatal("Binding should have non-empty key and action") + } +} + +func TestSelectedBindingNilWhenEmpty(t *testing.T) { + h := newTestHandler() + h.query = "zzzznonexistent" + h.updateFilter() + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding when no matches") + } +} + +func TestSelectedBindingNilWhenNegativeIndex(t *testing.T) { + h := newTestHandler() + h.updateFilter() + h.selected_idx = -1 + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for negative index") + } +} + +func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) { + h := newTestHandler() + h.updateFilter() + h.selected_idx = len(h.filtered_idx) + 10 + + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for overflow index") + } +} + +func TestSearchTextContainsKeyAndAction(t *testing.T) { + h := newTestHandler() + for i, item := range h.all_items { + if !strings.Contains(item.searchText, item.binding.Key) { + t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key) + } + if !strings.Contains(item.searchText, item.binding.ActionDisplay) { + t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay) + } + } +} + +func TestHelpTextPreserved(t *testing.T) { + h := newTestHandler() + helpCount := 0 + for _, item := range h.all_items { + if item.binding.Help != "" { + helpCount++ + } + } + if helpCount == 0 { + t.Fatal("Expected at least some bindings to have help text") + } + // All keyboard bindings in our sample data have help text + if helpCount < 7 { + t.Fatalf("Expected at least 7 bindings with help text, got %d", helpCount) + } +} + +func TestEmptyInputData(t *testing.T) { + h := &Handler{} + emptyJSON := `{"modes": {}, "mouse": [], "mode_order": [], "category_order": {}}` + if err := json.Unmarshal([]byte(emptyJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + h.updateFilter() + + if len(h.all_items) != 0 { + t.Fatalf("Expected 0 items for empty data, got %d", len(h.all_items)) + } + if len(h.filtered_idx) != 0 { + t.Fatalf("Expected 0 filtered items, got %d", len(h.filtered_idx)) + } + if b := h.selectedBinding(); b != nil { + t.Fatal("Expected nil binding for empty data") + } +} + +func TestFallbackOrderingWithoutExplicitOrder(t *testing.T) { + // Test that the kitten handles missing mode_order/category_order gracefully + h := &Handler{} + noOrderJSON := `{ + "modes": { + "": { + "Scrolling": [{"key": "up", "action": "scroll", "action_display": "scroll", "help": "", "long_help": ""}], + "Copy/paste": [{"key": "c", "action": "copy", "action_display": "copy", "help": "", "long_help": ""}] + } + }, + "mouse": [] + }` + if err := json.Unmarshal([]byte(noOrderJSON), &h.input_data); err != nil { + t.Fatal(err) + } + h.flattenBindings() + + if len(h.all_items) != 2 { + t.Fatalf("Expected 2 items, got %d", len(h.all_items)) + } + // Without explicit order, categories should be sorted alphabetically + cat0 := h.all_items[0].binding.Category + cat1 := h.all_items[1].binding.Category + if cat0 > cat1 { + t.Fatalf("Expected alphabetical category order, got %q then %q", cat0, cat1) + } +} diff --git a/kitty/boss.py b/kitty/boss.py index 6d6832194..71c681bee 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2294,6 +2294,12 @@ class Boss: def input_unicode_character(self) -> None: self.run_kitten_with_metadata('unicode_input', window=self.window_for_dispatch) + @ac('misc', 'Browse and trigger keyboard shortcuts and actions in a searchable overlay') + def command_palette(self) -> None: + from kittens.command_palette.main import collect_keys_data + data = collect_keys_data(get_options()) + self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch) + @ac( 'tab', ''' Change the title of the active tab interactively, by typing in the new title. diff --git a/kitty_tests/command_palette.py b/kitty_tests/command_palette.py new file mode 100644 index 000000000..30fb71842 --- /dev/null +++ b/kitty_tests/command_palette.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2024, Kovid Goyal + +from . import BaseTest + + +class TestCommandPalette(BaseTest): + + def test_collect_keys_data(self): + from kittens.command_palette.main import collect_keys_data + from kitty.actions import groups + opts = self.set_options() + data = collect_keys_data(opts) + self.assertIn('modes', data) + self.assertIn('mouse', data) + self.assertIn('', data['modes'], 'Default keyboard mode should be present') + 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()) + 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 + for cat_name, bindings in default_mode.items(): + self.assertIsInstance(bindings, list) + for b in bindings: + self.assertIn('key', b) + self.assertIn('action', b) + self.assertIn('action_display', b) + self.assertIn('help', b) + self.assertIn('long_help', b) + self.assertIsInstance(b['key'], str) + self.assertIsInstance(b['action'], str) + self.assertTrue(len(b['key']) > 0) + self.assertTrue(len(b['action']) > 0) + # Mouse mappings + self.assertIsInstance(data['mouse'], list) + for b in data['mouse']: + self.assertIn('key', b) + self.assertIn('action', b) + self.assertIn('action_display', b) + + def test_collect_keys_categories_ordered(self): + from kittens.command_palette.main import collect_keys_data + from kitty.actions import groups + opts = self.set_options() + data = collect_keys_data(opts) + default_mode = data['modes'][''] + cat_names = list(default_mode.keys()) + group_titles = list(groups.values()) + # Categories should appear in the same order as defined in groups + indices = [] + for cat in cat_names: + if cat in group_titles: + indices.append(group_titles.index(cat)) + self.ae(indices, sorted(indices), 'Categories should be ordered according to groups dict') + + 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) + 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') + + def test_collect_keys_has_help_text(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts) + # At least some bindings should have help text + has_help = False + for cat_name, bindings in data['modes'][''].items(): + for b in bindings: + if b['help']: + has_help = True + break + if has_help: + break + self.assertTrue(has_help, 'At least some bindings should have help text') + + def test_ordering_arrays_present(self): + from kittens.command_palette.main import collect_keys_data + opts = self.set_options() + data = collect_keys_data(opts) + # mode_order should list all modes + self.assertIn('mode_order', data) + self.assertIsInstance(data['mode_order'], list) + self.ae(set(data['mode_order']), set(data['modes'].keys())) + # category_order should list categories for each mode + self.assertIn('category_order', data) + self.assertIsInstance(data['category_order'], dict) + for mode_name in data['modes']: + self.assertIn(mode_name, data['category_order']) + self.ae( + set(data['category_order'][mode_name]), + set(data['modes'][mode_name].keys()), + f'category_order for mode {mode_name!r} should match modes keys', + ) diff --git a/shell-integration/ssh/kitty b/shell-integration/ssh/kitty index 08e30e9d2..0d1cda9a5 100755 --- a/shell-integration/ssh/kitty +++ b/shell-integration/ssh/kitty @@ -24,7 +24,7 @@ exec_kitty() { is_wrapped_kitten() { - wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files" + wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files command-palette" [ -n "$1" ] && { case " $wrapped_kittens " in *" $1 "*) printf "%s" "$1" ;; diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index c2f079460..6950039cc 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -7,6 +7,7 @@ import ( "github.com/kovidgoyal/kitty/kittens/ask" "github.com/kovidgoyal/kitty/kittens/choose_files" + "github.com/kovidgoyal/kitty/kittens/command_palette" "github.com/kovidgoyal/kitty/kittens/choose_fonts" "github.com/kovidgoyal/kitty/kittens/clipboard" "github.com/kovidgoyal/kitty/kittens/desktop_ui" @@ -97,6 +98,8 @@ func KittyToolEntryPoints(root *cli.Command) { choose_fonts.EntryPoint(root) // choose-files choose_files.EntryPoint(root) + // command-palette + command_palette.EntryPoint(root) // query-terminal query_terminal.EntryPoint(root) // __pytest__