diff --git a/docs/kittens/command-palette.rst b/docs/kittens/command-palette.rst new file mode 100644 index 000000000..95add0198 --- /dev/null +++ b/docs/kittens/command-palette.rst @@ -0,0 +1,114 @@ +Command palette +================= + +.. only:: man + + Overview + -------------- + + +The command palette lets you browse, search and trigger all keyboard shortcuts +and actions in |kitty| from a single searchable overlay. Press +:sc:`command_palette` to open it (default: :kbd:`Ctrl+Shift+F3`). + +.. figure:: ../screenshots/command-palette.png + :alt: A screenshot of the command palette kitten + :align: center + :width: 100% + + The command palette showing search results for "window close" + +All mapped actions (those with a keyboard shortcut) and unmapped actions (those +available but not bound to any key) are listed, organized by category. Mouse +bindings are shown in a separate section. Simply type to search, select a +result, and press :kbd:`Enter` to run it. + + +Searching +----------- + +As you type into the search bar, the palette filters results in real time using +word-level matching across three columns: key, action, and category. + +Multiple search terms are supported. Typing ``scroll page`` matches items that +contain both "scroll" and "page" in any column. Items matching more of your +search terms rank higher than those matching fewer. + +The search also handles compound tokens that contain delimiters such as +underscores or slashes. For example, typing ``mouse_selection`` matches the +full compound name as a unit. Typo tolerance is built in for words of four +characters or longer. + +Matched characters are highlighted in the results so you can see exactly where +each term matched. + + +Keyboard controls +------------------- + +The following keys are available while the command palette is open: + +.. list-table:: + :widths: auto + :header-rows: 1 + + * - Key + - Action + * - Any text + - Filter results by typing a search query + * - :kbd:`Enter` + - Run the selected action + * - :kbd:`Escape` + - Clear the search query, or close the palette if the query is already empty + * - :kbd:`Up` / :kbd:`Ctrl+K` / :kbd:`Ctrl+P` + - Move selection up + * - :kbd:`Down` / :kbd:`Ctrl+J` / :kbd:`Ctrl+N` + - Move selection down + * - :kbd:`Page Up` + - Move selection up by a page + * - :kbd:`Page Down` + - Move selection down by a page + * - :kbd:`Home` + - Jump to the first result + * - :kbd:`End` + - Jump to the last result + * - :kbd:`Backspace` + - Delete the last character from the query + * - :kbd:`F12` + - Toggle display of unmapped actions + * - Mouse click + - Select and run the clicked action + + +Unmapped actions +------------------ + +By default, the palette shows both mapped actions (those bound to a shortcut) +and unmapped actions (those with no shortcut assigned). Unmapped actions appear +with an ``(unmapped)`` label in the key column. Press :kbd:`F12` to toggle +their visibility. This preference is remembered across sessions. + +Unmapped actions are useful for discovering functionality that you may not have +configured a shortcut for. You can run them directly from the palette, or note +the action name and add a mapping in :file:`kitty.conf`. + + +Custom keyboard modes +----------------------- + +If you have defined custom :ref:`keyboard modes ` in your +configuration, their bindings appear under separate mode headers in the palette. +The ``push_keyboard_mode`` bindings are grouped with the target mode they +activate, making it easy to see how to enter each mode alongside its shortcuts. + + +Configuration +-------------- + +The default mapping to open the command palette is:: + + map kitty_mod+f3 command_palette + +You can change this in :file:`kitty.conf` like any other mapping. For example:: + + map ctrl+p command_palette diff --git a/docs/kittens_intro.rst b/docs/kittens_intro.rst index daa908116..b89b11154 100644 --- a/docs/kittens_intro.rst +++ b/docs/kittens_intro.rst @@ -13,6 +13,7 @@ Extend with kittens kittens/themes kittens/choose-fonts kittens/hints + kittens/command-palette kittens/quick-access-terminal kittens/choose-files kittens/panel @@ -54,6 +55,11 @@ Some prominent kittens: filenames, words, lines, etc. from the terminal screen. +:doc:`Command palette ` + Browse, search and trigger all keyboard shortcuts and actions from a + single searchable overlay. + + :doc:`Quick access terminal ` Get access to a quick access floating, semi-transparent kitty window with a single keypress. diff --git a/docs/screenshots/command-palette.png b/docs/screenshots/command-palette.png new file mode 100644 index 000000000..123057281 Binary files /dev/null and b/docs/screenshots/command-palette.png differ diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index e0d26653f..683d155ff 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/kovidgoyal/kitty/tools/cli" - "github.com/kovidgoyal/kitty/tools/fzf" "github.com/kovidgoyal/kitty/tools/tty" "github.com/kovidgoyal/kitty/tools/tui" "github.com/kovidgoyal/kitty/tools/tui/loop" @@ -39,16 +38,166 @@ type InputData struct { CategoryOrder map[string][]string `json:"category_order"` } -// DisplayItem wraps a binding with its per-column search texts for FZF scoring -type DisplayItem struct { - binding Binding - colTexts [3]string // [0]=key, [1]=action_display, [2]=category +// wordToken represents a single word extracted from column text, with its +// rune-level position in the original string for match highlighting. +type wordToken struct { + word string // lowercased word + startPos int // rune offset in original column text + endPos int // rune offset past last char } -// matchInfo stores which column matched and the matched character positions +// tokenizeWords splits s into words on delimiters (_ space + / -) and returns +// each word with its rune position in the original string. +func tokenizeWords(s string) []wordToken { + runes := []rune(strings.ToLower(s)) + var tokens []wordToken + start := -1 + for i, r := range runes { + if isWordDelimiter(r) { + if start >= 0 { + tokens = append(tokens, wordToken{ + word: string(runes[start:i]), + startPos: start, + endPos: i, + }) + start = -1 + } + } else if start < 0 { + start = i + } + } + if start >= 0 { + tokens = append(tokens, wordToken{ + word: string(runes[start:]), + startPos: start, + endPos: len(runes), + }) + } + return tokens +} + +// tokenizeQuery splits a query string into lowercase tokens on whitespace only. +// Delimiter characters like _ + / - are preserved within tokens so the user can +// search for compound names (e.g. "mouse_selection") as a single unit. +func tokenizeQuery(s string) []string { + parts := strings.Fields(s) + for i := range parts { + parts[i] = strings.ToLower(parts[i]) + } + return parts +} + +// isWordDelimiter returns true for characters used to split column text into words. +func isWordDelimiter(r rune) bool { + return r == '_' || r == ' ' || r == '+' || r == '/' || r == '-' +} + +// matchSingleWord finds the best-matching word for a simple (no-delimiter) query +// token against a column's pre-tokenized words. +// +// Scoring: exact=4, prefix=3, edit-distance-1=2, edit-distance-2=1, none=0. +func matchSingleWord(queryToken string, words []wordToken) (score int, positions []int) { + for _, w := range words { + var s int + var pos []int + + if w.word == queryToken { + // Exact match + s = 4 + pos = runeRange(w.startPos, w.endPos) + } else if strings.HasPrefix(w.word, queryToken) { + // Prefix match — highlight only the matched prefix + s = 3 + pos = runeRange(w.startPos, w.startPos+len([]rune(queryToken))) + } else if len(queryToken) >= 4 && len(w.word) >= 4 { + // Typo tolerance via edit distance (only for words >= 4 chars) + dist := utils.LevenshteinDistance(queryToken, w.word, false) + if dist == 1 { + s = 2 + pos = runeRange(w.startPos, w.endPos) + } else if dist == 2 { + s = 1 + pos = runeRange(w.startPos, w.endPos) + } + } + + if s > score { + score = s + positions = pos + } + } + return +} + +// bestWordMatch finds the best match for queryToken against a column's words. +// For compound tokens (containing _ + / -), it first tries an exact substring +// match against the full column text, then falls back to matching each sub-part +// independently against individual words. +func bestWordMatch(queryToken string, words []wordToken, colText string) (score int, positions []int) { + if !strings.ContainsAny(queryToken, "_+/-") { + return matchSingleWord(queryToken, words) + } + + // Compound token: try exact substring match in the column text + colLower := strings.ToLower(colText) + if idx := strings.Index(colLower, queryToken); idx != -1 { + runeIdx := len([]rune(colLower[:idx])) + qRuneLen := len([]rune(queryToken)) + subParts := strings.FieldsFunc(queryToken, isWordDelimiter) + return 4 * len(subParts), runeRange(runeIdx, runeIdx+qRuneLen) + } + + // Fallback: match each sub-part independently against words + subParts := strings.FieldsFunc(queryToken, isWordDelimiter) + var totalScore int + var allPos []int + for _, sub := range subParts { + s, p := matchSingleWord(sub, words) + totalScore += s + allPos = append(allPos, p...) + } + if totalScore > 0 { + return totalScore, allPos + } + return 0, nil +} + +// runeRange returns a slice of consecutive ints from start to end-1. +func runeRange(start, end int) []int { + pos := make([]int, end-start) + for i := range pos { + pos[i] = start + i + } + return pos +} + +// DisplayItem wraps a binding with its per-column search texts and pre-tokenized +// words for word-level matching. +type DisplayItem struct { + binding Binding + keyText string + actionText string + categoryText string + keyWords []wordToken + actionWords []wordToken + categoryWords []wordToken +} + +// matchInfo stores matched character positions per column for multi-token highlighting type matchInfo struct { - colIdx int // which column matched: 0=key, 1=action_display, 2=category - positions []int // rune positions in the matched column text + keyPositions []int // matched rune positions in key column + actionPositions []int // matched rune positions in action column + categoryPositions []int // matched rune positions in category column +} + +// scoredItem holds ranking data for a single item matching the current query. +type scoredItem struct { + idx int + nMatched int + actionScore int + keyScore int + categoryScore int + mi matchInfo } type displayLine struct { @@ -84,7 +233,24 @@ func truncateToWidth(s string, maxWidth int) string { return string(runes) + "..." } -// CachedSettings holds persistent UI settings stored in command-palette.json. +// sectionHeader returns a separator line like " ── label ─────────". +func sectionHeader(label string, width int) string { + labelWidth := wcswidth.Stringwidth(label) + sepLen := max(0, width-labelWidth-6) + sep := strings.Repeat("\u2500", sepLen) + return fmt.Sprintf(" \u2500\u2500 %s %s", label, sep) +} + +// keyDisplayText returns the display string for a binding's key column, +// substituting unmappedLabel for empty keys and truncating to maxKeyDisplayWidth. +func keyDisplayText(b *Binding) string { + key := b.Key + if key == "" { + key = unmappedLabel + } + return truncateToWidth(key, maxKeyDisplayWidth) +} + type CachedSettings struct { ShowUnmapped bool `json:"show_unmapped"` } @@ -93,7 +259,6 @@ type Handler struct { lp *loop.Loop screen_size loop.ScreenSize all_items []DisplayItem - matcher *fzf.FuzzyMatcher filtered_idx []int // indices into all_items for current results match_infos []matchInfo // parallel to filtered_idx, valid when query != "" query string @@ -129,7 +294,6 @@ func (h *Handler) initialize() (string, error) { return "", err } - h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) h.updateFilter() h.draw_screen() h.lp.SendOverlayReady() @@ -153,8 +317,8 @@ func (h *Handler) loadData() error { } // 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. +// 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. func (h *Handler) flattenBindings() { // Use explicit mode ordering from Python, falling back to sorted keys modeNames := h.input_data.ModeOrder @@ -204,8 +368,13 @@ func (h *Handler) flattenBindings() { keyText = unmappedLabel } h.all_items = append(h.all_items, DisplayItem{ - binding: b, - colTexts: [3]string{keyText, b.ActionDisplay, catName}, + binding: b, + keyText: keyText, + actionText: b.ActionDisplay, + categoryText: catName, + keyWords: tokenizeWords(keyText), + actionWords: tokenizeWords(b.ActionDisplay), + categoryWords: tokenizeWords(catName), }) } } @@ -217,14 +386,21 @@ func (h *Handler) flattenBindings() { b.Mode = "" b.IsMouse = true h.all_items = append(h.all_items, DisplayItem{ - binding: b, - colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"}, + binding: b, + keyText: b.Key, + actionText: b.ActionDisplay, + categoryText: "Mouse actions", + keyWords: tokenizeWords(b.Key), + actionWords: tokenizeWords(b.ActionDisplay), + categoryWords: tokenizeWords("Mouse actions"), }) } } func (h *Handler) updateFilter() { - if h.query == "" { + tokens := tokenizeQuery(h.query) + + if len(tokens) == 0 { // 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 { @@ -239,62 +415,60 @@ func (h *Handler) updateFilter() { return } - nItems := len(h.all_items) - - // Build per-column text slices for batch FZF scoring - colSlices := [3][]string{ - make([]string, nItems), - make([]string, nItems), - make([]string, nItems), - } - for i, item := range h.all_items { - colSlices[0][i] = item.colTexts[0] - colSlices[1][i] = item.colTexts[1] - colSlices[2][i] = item.colTexts[2] - } - - // Score each column independently - colResults := [3][]fzf.Result{} - for c := range 3 { - results, err := h.matcher.Score(colSlices[c], h.query) - if err == nil { - colResults[c] = results - } - } - - type scored struct { - idx int - score uint - colIdx int - positions []int - } - var matches []scored + var matches []scoredItem 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 - for c := range 3 { - if colResults[c] != nil && i < len(colResults[c]) && colResults[c][i].Score > bestScore { - bestScore = colResults[c][i].Score - bestCol = c - bestPositions = colResults[c][i].Positions + item := &h.all_items[i] + var s scoredItem + s.idx = i + + for _, qt := range tokens { + ks, kp := bestWordMatch(qt, item.keyWords, item.keyText) + as, ap := bestWordMatch(qt, item.actionWords, item.actionText) + cs, cp := bestWordMatch(qt, item.categoryWords, item.categoryText) + + best := max(ks, max(as, cs)) + if best > 0 { + s.nMatched++ } + s.keyScore += ks + s.actionScore += as + s.categoryScore += cs + s.mi.keyPositions = append(s.mi.keyPositions, kp...) + s.mi.actionPositions = append(s.mi.actionPositions, ap...) + s.mi.categoryPositions = append(s.mi.categoryPositions, cp...) } - if bestScore > 0 { - matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions}) + + if s.nMatched > 0 { + matches = append(matches, s) } } + + // Sort: most tokens matched > actionScore > keyScore > categoryScore > shorter ActionDisplay sort.Slice(matches, func(i, j int) bool { - return matches[i].score > matches[j].score + if matches[i].nMatched != matches[j].nMatched { + return matches[i].nMatched > matches[j].nMatched + } + if matches[i].actionScore != matches[j].actionScore { + return matches[i].actionScore > matches[j].actionScore + } + if matches[i].keyScore != matches[j].keyScore { + return matches[i].keyScore > matches[j].keyScore + } + if matches[i].categoryScore != matches[j].categoryScore { + return matches[i].categoryScore > matches[j].categoryScore + } + return len(h.all_items[matches[i].idx].binding.ActionDisplay) < len(h.all_items[matches[j].idx].binding.ActionDisplay) }) + + // Build filtered_idx and match_infos h.filtered_idx = make([]int, len(matches)) h.match_infos = make([]matchInfo, len(matches)) for i, m := range matches { h.filtered_idx[i] = m.idx - h.match_infos[i] = matchInfo{colIdx: m.colIdx, positions: m.positions} + h.match_infos[i] = m.mi } h.selected_idx = 0 h.scroll_offset = 0 @@ -377,16 +551,7 @@ func (h *Handler) draw_screen() { // Draw help text for selected binding h.lp.MoveCursorTo(1, helpY) if b := h.selectedBinding(); b != nil && b.Help != "" { - helpStr := b.Help - maxLen := max(width-2, 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) + "..." - } + helpStr := truncateToWidth(b.Help, max(width-2, 3)) h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr)) } @@ -400,11 +565,11 @@ func (h *Handler) draw_screen() { h.lp.SprintStyled("fg=bright-yellow", "[Esc]") + " Quit " + h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate " + h.lp.SprintStyled("fg=bright-yellow", "[F12]") + " " + unmappedToggleLabel + " unmapped" - matchInfo := "" + matchCount := "" if h.query != "" { - matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) + matchCount = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items)) } - h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo)) + h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchCount)) // Position cursor at end of search text for typing h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY) @@ -424,16 +589,11 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { lastMode = b.Mode lastCategory = "" if b.Mode != "" { - // Non-default mode: show "── Keyboard mode: name ──" header (purple), no category separators if len(lines) > 0 { lines = append(lines, displayLine{itemIdx: -1, isHeader: true}) } - label := "Keyboard mode: " + b.Mode - labelWidth := wcswidth.Stringwidth(label) - sepLen := max(0, width-labelWidth-6) - sep := strings.Repeat("\u2500", sepLen) lines = append(lines, displayLine{ - text: fmt.Sprintf(" \u2500\u2500 %s %s", label, sep), + text: sectionHeader("Keyboard mode: "+b.Mode, width), isModeHdr: true, isHeader: true, itemIdx: -1, }) } @@ -445,21 +605,14 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) { 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), + text: sectionHeader(b.Category, width), isHeader: true, itemIdx: -1, }) } - // Binding line — key column shows "(unmapped)" for actions with no shortcut - keyDisplay := b.Key - if keyDisplay == "" { - keyDisplay = unmappedLabel - } - keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth) + // Binding line + keyDisplay := keyDisplayText(b) lines = append(lines, displayLine{ text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay), itemIdx: fi, @@ -482,11 +635,7 @@ func (h *Handler) drawFlatResults(startY, maxRows, width int) { for fi, idx := range h.filtered_idx { item := &h.all_items[idx] b := &item.binding - keyDisplay := b.Key - if keyDisplay == "" { - keyDisplay = unmappedLabel - } - keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth) + keyDisplay := keyDisplayText(b) catSuffix := "" if b.Mode != "" { catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category) @@ -549,37 +698,26 @@ func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) { } 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)) + // Selected item: highlight with reverse video, preserving match highlights + h.drawBindingLine(li.itemIdx, width, true) } else { - h.drawBindingLine(text, li.itemIdx, width) + h.drawBindingLine(li.itemIdx, width, false) } } } -func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { +func (h *Handler) drawBindingLine(filteredIdx, width int, isSelected bool) { 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 - // Build the key display (using unmappedLabel for items with no shortcut) - rawKey := b.Key - if rawKey == "" { - rawKey = unmappedLabel - } - keyDisplay := truncateToWidth(rawKey, maxKeyDisplayWidth) + // Build the key display + keyDisplay := keyDisplayText(b) // Determine match info for highlighting (only set when a query is active) var mi *matchInfo @@ -587,50 +725,77 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) { mi = &h.match_infos[filteredIdx] } - const matchStyle = "fg=bright-yellow" - const keyStyle = "fg=green" - const unmappedStyle = "dim fg=green" + // Style definitions vary based on whether this row is selected + var matchStyle, keyStyle, unmappedStyle, baseStyle string + if isSelected { + matchStyle = "fg=bright-yellow reverse" + keyStyle = "fg=green reverse" + unmappedStyle = "dim fg=green reverse" + baseStyle = "reverse" + } else { + matchStyle = "fg=bright-yellow" + keyStyle = "fg=green" + unmappedStyle = "dim fg=green" + } + + // styled applies baseStyle to s when selected, or returns s unchanged. + styled := func(s string) string { + if baseStyle != "" { + return h.lp.SprintStyled(baseStyle, s) + } + return s + } // Render key column (4-space indent + key padded to maxKeyDisplayWidth + space) paddingLen := max(0, maxKeyDisplayWidth-wcswidth.Stringwidth(keyDisplay)) - if mi != nil && mi.colIdx == 0 { - ks := keyStyle - if b.Key == "" { - ks = unmappedStyle - } - h.lp.QueueWriteString(" ") - h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.positions, ks, matchStyle)) - h.lp.QueueWriteString(strings.Repeat(" ", paddingLen) + " ") - } else if b.Key == "" { - h.lp.QueueWriteString(h.lp.SprintStyled(unmappedStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" ")) + pad := strings.Repeat(" ", paddingLen) + " " + ks := keyStyle + if b.Key == "" { + ks = unmappedStyle + } + if mi != nil && len(mi.keyPositions) > 0 { + h.lp.QueueWriteString(styled(" ")) + h.lp.QueueWriteString(h.highlightMatchedChars(keyDisplay, mi.keyPositions, ks, matchStyle)) + h.lp.QueueWriteString(styled(pad)) } else { - h.lp.QueueWriteString(h.lp.SprintStyled(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" ")) + h.lp.QueueWriteString(h.lp.SprintStyled(ks, " "+keyDisplay+pad)) } // Render action display column - if mi != nil && mi.colIdx == 1 { - h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle)) + if mi != nil && len(mi.actionPositions) > 0 { + h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.actionPositions, baseStyle, matchStyle)) } else { - h.lp.QueueWriteString(b.ActionDisplay) + h.lp.QueueWriteString(styled(b.ActionDisplay)) } // Render category suffix (only present in flat / search-results mode) if h.query != "" { - if mi != nil && mi.colIdx == 2 { - if b.Mode != "" { - h.lp.QueueWriteString(fmt.Sprintf(" [%s/", b.Mode)) - } else { - h.lp.QueueWriteString(" [") - } - h.lp.QueueWriteString(h.highlightMatchedChars(b.Category, mi.positions, "", matchStyle)) - h.lp.QueueWriteString("]") + 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 + "]")) + } + } + + // 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) + if h.query != "" { + rendered += 2 + wcswidth.Stringwidth(b.Category) + 1 if b.Mode != "" { - h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)) - } else { - h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category)) + rendered += wcswidth.Stringwidth(b.Mode) + 1 } } + if pad := width - rendered; pad > 0 { + h.lp.QueueWriteString(h.lp.SprintStyled(baseStyle, strings.Repeat(" ", pad))) + } } } diff --git a/kittens/command_palette/main_test.go b/kittens/command_palette/main_test.go index a489e608a..66d59f280 100644 --- a/kittens/command_palette/main_test.go +++ b/kittens/command_palette/main_test.go @@ -5,53 +5,99 @@ import ( "fmt" "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", "definition": "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", "definition": "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", "definition": "scroll_line_up", "help": "Scroll up one line", "long_help": ""}, - {"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "definition": "scroll_line_down", "help": "Scroll down one line", "long_help": ""} - ], - "Window management": [ - {"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open a new window", "long_help": ""} - ] - }, - "mw": { - "Miscellaneous": [ - {"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "definition": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""}, - {"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "definition": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""} - ] - } +// testBinding creates a Binding where Action, ActionDisplay, and Definition all +// equal action. Covers 90% of test bindings. +func testBinding(key, action, help string) Binding { + return Binding{ + Key: key, + Action: action, + ActionDisplay: action, + Definition: action, + Help: help, + } +} + +// testMouseBinding creates a mouse Binding where ActionDisplay differs from +// Action. Action is derived as the first word of actionDisplay. +func testMouseBinding(key, actionDisplay string) Binding { + action := actionDisplay + if idx := strings.IndexByte(actionDisplay, ' '); idx >= 0 { + action = actionDisplay[:idx] + } + return Binding{ + Key: key, + Action: action, + ActionDisplay: actionDisplay, + Definition: actionDisplay, + } +} + +// testHandlerBuilder constructs a Handler with programmatic data (no JSON round-trip). +type testHandlerBuilder struct { + input InputData +} + +func newBuilder() *testHandlerBuilder { + return &testHandlerBuilder{ + input: InputData{ + Modes: make(map[string]map[string][]Binding), + CategoryOrder: make(map[string][]string), }, - "mouse": [ - {"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "definition": "mouse_selection normal", "help": "", "long_help": ""}, - {"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "definition": "mouse_selection rectangle", "help": "", "long_help": ""} - ], - "mode_order": ["", "mw"], - "category_order": { - "": ["Copy/paste", "Scrolling", "Window management"], - "mw": ["Miscellaneous"] - } - }` + } +} + +func (b *testHandlerBuilder) addBinding(mode, category string, binding Binding) *testHandlerBuilder { + if b.input.Modes[mode] == nil { + b.input.Modes[mode] = make(map[string][]Binding) + b.input.ModeOrder = append(b.input.ModeOrder, mode) + } + cats := b.input.Modes[mode] + if _, ok := cats[category]; !ok { + b.input.CategoryOrder[mode] = append(b.input.CategoryOrder[mode], category) + } + cats[category] = append(cats[category], binding) + return b +} + +func (b *testHandlerBuilder) addMouse(binding Binding) *testHandlerBuilder { + b.input.Mouse = append(b.input.Mouse, binding) + return b +} + +func (b *testHandlerBuilder) build() *Handler { + h := &Handler{} + h.input_data = b.input + h.flattenBindings() + h.show_unmapped = true + return h } 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 + return newBuilder(). + addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy the selected text from the active window to the clipboard")). + addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from the clipboard to the active window")). + addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")). + addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")). + addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")). + addBinding("mw", "Miscellaneous", Binding{ + Key: "left", Action: "neighboring_window", + ActionDisplay: "neighboring_window left", + Definition: "neighboring_window left", Help: "Focus neighbor window", + }). + addBinding("mw", "Miscellaneous", testBinding("esc", "pop_keyboard_mode", "Pop keyboard mode")). + addMouse(Binding{ + Key: "left press ungrabbed", Action: "mouse_selection", + ActionDisplay: "mouse_selection normal", + Definition: "mouse_selection normal", + }). + addMouse(Binding{ + Key: "ctrl+left press ungrabbed", Action: "mouse_selection", + ActionDisplay: "mouse_selection rectangle", + Definition: "mouse_selection rectangle", + }). + build() } func TestFlattenAllBindings(t *testing.T) { @@ -156,14 +202,12 @@ func TestFilterMatchesSubset(t *testing.T) { // Verify all returned items contain relevant text in at least one column for _, idx := range h.filtered_idx { item := &h.all_items[idx] - found := false - for _, col := range item.colTexts { - if strings.Contains(strings.ToLower(col), "clipboard") { - found = true - break - } + found := strings.Contains(strings.ToLower(item.keyText), "clipboard") || + strings.Contains(strings.ToLower(item.actionText), "clipboard") || + strings.Contains(strings.ToLower(item.categoryText), "clipboard") + if !found { + t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText) } - _ = found // FZF does fuzzy matching, so this is a soft check } } @@ -239,16 +283,16 @@ func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) { func TestSearchTextContainsKeyAndAction(t *testing.T) { h := newTestHandler() for i, item := range h.all_items { - // colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category + // keyText = key (or unmappedLabel for empty key), actionText = action_display, categoryText = category expectedKey := item.binding.Key if expectedKey == "" { expectedKey = unmappedLabel } - if !strings.Contains(item.colTexts[0], expectedKey) { - t.Fatalf("Item %d: colTexts[0] %q should contain key %q", i, item.colTexts[0], expectedKey) + if !strings.Contains(item.keyText, expectedKey) { + t.Fatalf("Item %d: keyText %q should contain key %q", i, item.keyText, expectedKey) } - if !strings.Contains(item.colTexts[1], item.binding.ActionDisplay) { - t.Fatalf("Item %d: colTexts[1] %q should contain action %q", i, item.colTexts[1], item.binding.ActionDisplay) + if !strings.Contains(item.actionText, item.binding.ActionDisplay) { + t.Fatalf("Item %d: actionText %q should contain action %q", i, item.actionText, item.binding.ActionDisplay) } } } @@ -271,13 +315,7 @@ func TestHelpTextPreserved(t *testing.T) { } 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 := newBuilder().build() h.updateFilter() if len(h.all_items) != 0 { @@ -560,7 +598,7 @@ func TestScrollAdjustRevealsSectionHeader(t *testing.T) { } } -func TestColTextsPopulated(t *testing.T) { +func TestDisplayItemFieldsPopulated(t *testing.T) { h := newTestHandler() for i, item := range h.all_items { if item.binding.IsMouse { @@ -570,14 +608,14 @@ func TestColTextsPopulated(t *testing.T) { if expectedKey == "" { expectedKey = unmappedLabel } - if item.colTexts[0] != expectedKey { - t.Fatalf("Item %d: colTexts[0]=%q expected %q", i, item.colTexts[0], expectedKey) + if item.keyText != expectedKey { + t.Fatalf("Item %d: keyText=%q expected %q", i, item.keyText, expectedKey) } - if item.colTexts[1] != item.binding.ActionDisplay { - t.Fatalf("Item %d: colTexts[1]=%q expected %q", i, item.colTexts[1], item.binding.ActionDisplay) + if item.actionText != item.binding.ActionDisplay { + t.Fatalf("Item %d: actionText=%q expected %q", i, item.actionText, item.binding.ActionDisplay) } - if item.colTexts[2] != item.binding.Category { - t.Fatalf("Item %d: colTexts[2]=%q expected %q", i, item.colTexts[2], item.binding.Category) + if item.categoryText != item.binding.Category { + t.Fatalf("Item %d: categoryText=%q expected %q", i, item.categoryText, item.binding.Category) } } } @@ -594,14 +632,12 @@ func TestFilterSingleColumnMatch(t *testing.T) { // All matched items should have 'scroll' in exactly one column, not spread across columns for _, idx := range h.filtered_idx { item := &h.all_items[idx] - colMatch := false - for _, col := range item.colTexts { - if strings.Contains(strings.ToLower(col), "scroll") { - colMatch = true - break - } + colMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") || + strings.Contains(strings.ToLower(item.actionText), "scroll") || + strings.Contains(strings.ToLower(item.categoryText), "scroll") + if !colMatch { + t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText) } - _ = colMatch // FZF does fuzzy matching; at least the characters should appear in one column } } @@ -616,8 +652,8 @@ func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) { t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx)) } for i, mi := range h.match_infos { - if mi.colIdx < 0 || mi.colIdx > 2 { - t.Fatalf("match_infos[%d].colIdx=%d out of range [0,2]", i, mi.colIdx) + if len(mi.keyPositions) == 0 && len(mi.actionPositions) == 0 && len(mi.categoryPositions) == 0 { + t.Fatalf("match_infos[%d] has no positions in any column", i) } } } @@ -633,25 +669,10 @@ func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) { func TestUnmappedActionDisplayed(t *testing.T) { // Inject an item with an empty key (unmapped action) and verify display - h := &Handler{} - unmappedJSON := `{ - "modes": { - "": { - "Miscellaneous": [ - {"key": "ctrl+n", "action": "new_window", "action_display": "new_window", "definition": "new_window", "help": "Open new window", "long_help": ""}, - {"key": "", "action": "scroll_home", "action_display": "scroll_home", "definition": "scroll_home", "help": "Scroll to top", "long_help": ""} - ] - } - }, - "mouse": [], - "mode_order": [""], - "category_order": {"": ["Miscellaneous"]} - }` - if err := json.Unmarshal([]byte(unmappedJSON), &h.input_data); err != nil { - t.Fatal(err) - } - h.flattenBindings() - h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME) + h := newBuilder(). + addBinding("", "Miscellaneous", testBinding("ctrl+n", "new_window", "Open new window")). + addBinding("", "Miscellaneous", testBinding("", "scroll_home", "Scroll to top")). + build() if len(h.all_items) != 2 { t.Fatalf("Expected 2 items, got %d", len(h.all_items)) @@ -667,9 +688,9 @@ func TestUnmappedActionDisplayed(t *testing.T) { if unmapped == nil { t.Fatal("Expected to find unmapped item") } - // colTexts[0] should be unmappedLabel, not empty - if unmapped.colTexts[0] != unmappedLabel { - t.Fatalf("Expected colTexts[0]=%q for unmapped item, got %q", unmappedLabel, unmapped.colTexts[0]) + // keyText should be unmappedLabel, not empty + if unmapped.keyText != unmappedLabel { + t.Fatalf("Expected keyText=%q for unmapped item, got %q", unmappedLabel, unmapped.keyText) } // With show_unmapped=true, unmapped action should be searchable @@ -694,25 +715,11 @@ func TestUnmappedActionDisplayed(t *testing.T) { 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) + h := newBuilder(). + addBinding("", "Copy/paste", testBinding("ctrl+c", "copy", "Copy")). + addBinding("", "Copy/paste", testBinding("", "paste_from_buffer", "Paste from buffer")). + build() + h.show_unmapped = false // override default from build() if len(h.all_items) != 2 { t.Fatalf("Expected 2 items in all_items, got %d", len(h.all_items)) @@ -745,3 +752,644 @@ func TestShowUnmappedToggle(t *testing.T) { } } } + +// newMultiTokenTestHandler creates a handler with items designed to test +// multi-token search. It has items where different tokens match different +// columns, enabling cross-column and RRF ranking tests. +func newMultiTokenTestHandler() *Handler { + return newBuilder(). + addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")). + addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")). + addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up one line")). + addBinding("", "Scrolling", testBinding("ctrl+shift+down", "scroll_line_down", "Scroll down one line")). + addBinding("", "Scrolling", testBinding("ctrl+shift+page_up", "scroll_page_up", "Scroll up one page")). + addBinding("", "Scrolling", testBinding("ctrl+shift+home", "scroll_home", "Scroll to top")). + addBinding("", "Window management", testBinding("ctrl+shift+enter", "new_window", "Open a new window")). + addBinding("", "Window management", testBinding("ctrl+shift+w", "close_window", "Close the active window")). + addBinding("", "Tab management", testBinding("ctrl+shift+t", "new_tab", "Open a new tab")). + addBinding("", "Tab management", testBinding("ctrl+shift+q", "close_tab", "Close the active tab")). + build() +} + +func TestMultiTokenAllMatchRanksAbovePartial(t *testing.T) { + // An item matching ALL tokens should rank above an item matching only SOME tokens. + // "scroll up" should rank scroll_line_up and scroll_page_up (both tokens match) + // above scroll_home or scroll_line_down (only "scroll" matches). + h := newMultiTokenTestHandler() + h.query = "scroll up" + h.updateFilter() + + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'scroll up'") + } + + // Items matching both "scroll" and "up" should appear before items matching only one token. + // scroll_line_up and scroll_page_up match both; scroll_line_down and scroll_home match only "scroll". + topResults := make([]string, 0) + for i, idx := range h.filtered_idx { + action := h.all_items[idx].binding.ActionDisplay + if i < 2 { + topResults = append(topResults, action) + } + } + for _, action := range topResults { + if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") { + t.Fatalf("Top result %q should match both 'scroll' and 'up'", action) + } + } +} + +func TestMultiTokenCrossColumnMatch(t *testing.T) { + // A query with tokens that match different columns should find the item. + // "ctrl+shift+c copy" — "ctrl+shift+c" matches the key column, + // "copy" matches the action column. + h := newMultiTokenTestHandler() + h.query = "ctrl+shift+c copy" + h.updateFilter() + + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for cross-column query 'ctrl+shift+c copy'") + } + + // copy_to_clipboard (key=ctrl+shift+c, action=copy_to_clipboard) should be the top result + topAction := h.all_items[h.filtered_idx[0]].binding.ActionDisplay + if topAction != "copy_to_clipboard" { + t.Fatalf("Expected top result to be 'copy_to_clipboard', got %q", topAction) + } +} + +func TestMultiTokenCrossColumnCategoryMatch(t *testing.T) { + // A token matching the category column combined with a token matching the action column. + // "window close" — "window" matches category "Window management", + // "close" matches action "close_window". + h := newMultiTokenTestHandler() + h.query = "window close" + h.updateFilter() + + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'window close'") + } + + // close_window should rank highly since both tokens match + found := false + for _, idx := range h.filtered_idx[:min(3, len(h.filtered_idx))] { + if h.all_items[idx].binding.ActionDisplay == "close_window" { + found = true + break + } + } + if !found { + t.Fatal("Expected 'close_window' in top results for 'window close'") + } +} + +func TestMultiTokenExtraWhitespace(t *testing.T) { + // Extra whitespace in the query should not produce empty/ghost tokens. + // " scroll up " should behave the same as "scroll up". + h := newMultiTokenTestHandler() + h.query = "scroll up" + h.updateFilter() + normalResults := make([]int, len(h.filtered_idx)) + copy(normalResults, h.filtered_idx) + + h.query = " scroll up " + h.updateFilter() + + if len(h.filtered_idx) != len(normalResults) { + t.Fatalf("Extra whitespace changed result count: %d vs %d", len(h.filtered_idx), len(normalResults)) + } + for i := range h.filtered_idx { + if h.filtered_idx[i] != normalResults[i] { + t.Fatalf("Extra whitespace changed result order at position %d", i) + } + } +} + +func TestMultiTokenAllWhitespaceIsEmptyQuery(t *testing.T) { + // A query that is only whitespace should behave like an empty query: + // return all items in original order with no match_infos. + h := newMultiTokenTestHandler() + h.query = " " + h.updateFilter() + + if len(h.filtered_idx) != len(h.all_items) { + t.Fatalf("All-whitespace query should return all %d items, got %d", len(h.all_items), len(h.filtered_idx)) + } + if h.match_infos != nil { + t.Fatal("All-whitespace query should produce nil match_infos") + } +} + +func TestMultiTokenSingleTokenRegression(t *testing.T) { + // A single-token query (no spaces) should produce the same results as before. + h := newMultiTokenTestHandler() + h.query = "clipboard" + h.updateFilter() + + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for single token 'clipboard'") + } + // All results should have "clipboard" matched in at least one column + for _, idx := range h.filtered_idx { + item := &h.all_items[idx] + anyMatch := strings.Contains(strings.ToLower(item.keyText), "clipboard") || + strings.Contains(strings.ToLower(item.actionText), "clipboard") || + strings.Contains(strings.ToLower(item.categoryText), "clipboard") + if !anyMatch { + t.Fatalf("Matched item %q has no column containing 'clipboard'", item.actionText) + } + } + // match_infos should still be parallel to filtered_idx + if len(h.match_infos) != len(h.filtered_idx) { + t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx)) + } +} + +func TestMultiTokenNoMatchReturnsEmpty(t *testing.T) { + // When no item matches any token, the result should be empty. + h := newMultiTokenTestHandler() + h.query = "zzzzz xxxxx" + h.updateFilter() + + if len(h.filtered_idx) != 0 { + t.Fatalf("Expected no matches for nonsense multi-token query, got %d", len(h.filtered_idx)) + } +} + +func TestMultiTokenPartialMatchStillShown(t *testing.T) { + // Items matching only some tokens should still appear, + // but ranked lower than items matching all tokens. + h := newMultiTokenTestHandler() + h.query = "scroll zzzznonexistent" + h.updateFilter() + + // "scroll" matches several items, "zzzznonexistent" matches nothing. + // Items matching "scroll" should still appear. + if len(h.filtered_idx) == 0 { + t.Fatal("Expected partial matches when one token matches and one doesn't") + } + + // Verify that matched items are related to "scroll" + for _, idx := range h.filtered_idx { + item := &h.all_items[idx] + anyMatch := strings.Contains(strings.ToLower(item.keyText), "scroll") || + strings.Contains(strings.ToLower(item.actionText), "scroll") || + strings.Contains(strings.ToLower(item.categoryText), "scroll") + if !anyMatch { + t.Fatalf("Matched item %q has no column containing 'scroll'", item.actionText) + } + } +} + +func TestMultiTokenMatchInfosTrackMultipleColumns(t *testing.T) { + // When tokens match different columns, match_infos should reflect + // positions in each matched column so highlighting works correctly. + h := newMultiTokenTestHandler() + h.query = "tab close" + h.updateFilter() + + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'tab close'") + } + if len(h.match_infos) != len(h.filtered_idx) { + t.Fatalf("match_infos length %d != filtered_idx length %d", len(h.match_infos), len(h.filtered_idx)) + } + + // Find close_tab — "tab" matches category "Tab management" and action "close_tab", + // "close" matches action "close_tab". match_infos must have positions in multiple columns. + for fi, idx := range h.filtered_idx { + if h.all_items[idx].binding.ActionDisplay == "close_tab" { + mi := h.match_infos[fi] + if len(mi.actionPositions) == 0 { + t.Fatal("close_tab: expected match positions in action column") + } + if len(mi.categoryPositions) == 0 { + t.Fatal("close_tab: expected match positions in category column for 'tab' in 'Tab management'") + } + return + } + } + t.Fatal("Expected close_tab in results for 'tab close'") +} + +func TestMultiTokenOrderIndependence(t *testing.T) { + // Token order should not matter: "close window" and "window close" + // should produce the same set of results (possibly in different order, + // but the same items). + h := newMultiTokenTestHandler() + + h.query = "window close" + h.updateFilter() + results1 := make(map[int]bool) + for _, idx := range h.filtered_idx { + results1[idx] = true + } + + h.query = "close window" + h.updateFilter() + results2 := make(map[int]bool) + for _, idx := range h.filtered_idx { + results2[idx] = true + } + + if len(results1) != len(results2) { + t.Fatalf("Token order changed result count: %d vs %d", len(results1), len(results2)) + } + for idx := range results1 { + if !results2[idx] { + t.Fatalf("Item %d present in 'window close' but not 'close window'", idx) + } + } +} + +// topActions returns the action_display names of the first n results after +// running query on h. It is a test helper for verifying ranking. +func topActions(h *Handler, query string, n int) []string { + h.query = query + h.updateFilter() + var result []string + for i, idx := range h.filtered_idx { + if i >= n { + break + } + result = append(result, h.all_items[idx].binding.ActionDisplay) + } + return result +} + +func TestQueryRankingScrollUp(t *testing.T) { + h := newMultiTokenTestHandler() + top := topActions(h, "scroll up", 4) + if len(top) < 4 { + t.Fatalf("Expected at least 4 results for 'scroll up', got %d", len(top)) + } + // Top 2 should match both "scroll" and "up", with scroll_line_up first (shorter) + for _, action := range top[:2] { + if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") { + t.Fatalf("Top result %q should match both 'scroll' and 'up'", action) + } + } + if top[0] != "scroll_line_up" { + t.Fatalf("Expected scroll_line_up first, got %q", top[0]) + } + if top[1] != "scroll_page_up" { + t.Fatalf("Expected scroll_page_up second, got %q", top[1]) + } + // Items matching only "scroll" (not "up") should rank below + for _, action := range top[2:] { + if strings.Contains(action, "up") { + continue // other "up" matches are fine here + } + if !strings.Contains(action, "scroll") { + t.Fatalf("Lower result %q should still contain 'scroll'", action) + } + } +} + +func TestQueryRankingNewWindow(t *testing.T) { + h := newMultiTokenTestHandler() + top := topActions(h, "new window", 3) + if len(top) == 0 { + t.Fatal("Expected results for 'new window'") + } + if top[0] != "new_window" { + t.Fatalf("Expected new_window first, got %q", top[0]) + } + // close_window should not appear above new_window + for i, action := range top { + if action == "close_window" && i == 0 { + t.Fatal("close_window should not be the top result for 'new window'") + } + } +} + +func TestQueryRankingCloseTab(t *testing.T) { + h := newMultiTokenTestHandler() + top := topActions(h, "close tab", 3) + if len(top) == 0 { + t.Fatal("Expected results for 'close tab'") + } + if top[0] != "close_tab" { + t.Fatalf("Expected close_tab first, got %q", top[0]) + } +} + +func TestQueryRankingSingleToken(t *testing.T) { + h := newMultiTokenTestHandler() + top := topActions(h, "clipboard", 2) + if len(top) < 2 { + t.Fatalf("Expected at least 2 results for 'clipboard', got %d", len(top)) + } + // copy_to_clipboard is shorter than paste_from_clipboard + if top[0] != "copy_to_clipboard" { + t.Fatalf("Expected copy_to_clipboard first, got %q", top[0]) + } + if top[1] != "paste_from_clipboard" { + t.Fatalf("Expected paste_from_clipboard second, got %q", top[1]) + } +} + +func TestQueryRankingExactActionMatch(t *testing.T) { + h := newMultiTokenTestHandler() + top := topActions(h, "new_tab", 1) + if len(top) == 0 { + t.Fatal("Expected results for 'new_tab'") + } + if top[0] != "new_tab" { + t.Fatalf("Expected new_tab first, got %q", top[0]) + } +} + +// newMouseTestHandler creates a handler with realistic mouse bindings matching +// the actual kitty command palette data, to test ranking of mouse_selection queries. +// Includes keyboard bindings with "selection" in their names to ensure mouse_selection +// items rank above them for the query "mouse selection". +func newMouseTestHandler() *Handler { + return newBuilder(). + addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")). + addBinding("", "Copy/paste", testBinding("ctrl+shift+c", "copy_to_clipboard", "Copy selected text")). + addBinding("", "Copy/paste", testBinding("shift+insert", "paste_selection", "Paste from primary selection")). + addBinding("", "Copy/paste", testBinding("ctrl+shift+v", "paste_from_clipboard", "Paste from clipboard")). + addBinding("", "Copy/paste", testBinding("", "copy_or_interrupt", "Copy selection or interrupt")). + addBinding("", "Copy/paste", testBinding("", "copy_and_clear_or_interrupt", "Copy selection and clear")). + addBinding("", "Copy/paste", testBinding("", "pass_selection_to_program", "Pass selection to program")). + addMouse(testMouseBinding("shift+left click ungrabbed", "mouse_handle_click selection link prompt")). + addMouse(testMouseBinding("shift+left click grabbed ungrabbed", "mouse_handle_click selection link prompt")). + addMouse(testMouseBinding("ctrl+shift+left release grabbed ungrabbed", "mouse_handle_click link")). + addMouse(testMouseBinding("shift+middle release ungrabbed grabbed", "paste_selection")). + addMouse(testMouseBinding("middle release ungrabbed", "paste_from_selection")). + addMouse(testMouseBinding("left drag ungrabbed", "mouse_selection")). + addMouse(testMouseBinding("shift+left drag ungrabbed", "mouse_selection")). + addMouse(testMouseBinding("left triplepress ungrabbed", "mouse_selection line")). + addMouse(testMouseBinding("shift+left doublepress ungrabbed", "mouse_selection word")). + addMouse(testMouseBinding("shift+left triplepress ungrabbed", "mouse_selection line_from_point")). + addMouse(testMouseBinding("shift+left triplepress+grabbed", "mouse_selection line_from_point")). + addMouse(testMouseBinding("right press ungrabbed", "mouse_selection extend")). + addMouse(testMouseBinding("shift+left press ungrabbed", "mouse_selection extend")). + addMouse(testMouseBinding("left press ungrabbed", "mouse_selection normal")). + addMouse(testMouseBinding("ctrl+left press ungrabbed", "mouse_selection rectangle")). + addMouse(testMouseBinding("ctrl+shift+right press ungrabbed", "mouse_selection rectangle extend")). + addMouse(testMouseBinding("ctrl+shift+left press ungrabbed", "mouse_selection rectangle extend")). + addMouse(testMouseBinding("shift+left triplepress", "mouse_selection line_from_point")). + addMouse(testMouseBinding("left press", "mouse_selection normal")). + build() +} + +// searchResult captures the full display state of a single result row: +// all three visible columns plus which columns have highlighted match positions. +type searchResult struct { + key string // key binding + action string // action_display + category string // category + // Which columns have highlighted (matched) character positions. + keyMatch bool + actionMatch bool + categoryMatch bool +} + +// queryResults runs query on h and returns the full display state of each result. +func queryResults(h *Handler, query string) []searchResult { + h.query = query + h.updateFilter() + results := make([]searchResult, len(h.filtered_idx)) + for i, idx := range h.filtered_idx { + item := &h.all_items[idx] + results[i] = searchResult{ + key: item.keyText, + action: item.actionText, + category: item.categoryText, + keyMatch: len(h.match_infos[i].keyPositions) > 0, + actionMatch: len(h.match_infos[i].actionPositions) > 0, + categoryMatch: len(h.match_infos[i].categoryPositions) > 0, + } + } + return results +} + +func TestQueryRankingMouseSelection(t *testing.T) { + h := newMouseTestHandler() + results := queryResults(h, "mouse selection") + + if len(results) == 0 { + t.Fatal("Expected results for 'mouse selection'") + } + + // Bare "mouse_selection" (shortest, exact match for both tokens) must rank + // above all suffixed variants like mouse_selection line/word/extend/normal. + if results[0].action != "mouse_selection" { + t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action) + } + + // All mouse_selection variants (action starts with "mouse_selection") must + // rank above any non-mouse_selection item. + lastMouseSelection := -1 + firstOther := -1 + for i, r := range results { + if strings.HasPrefix(r.action, "mouse_selection") { + lastMouseSelection = i + } else if firstOther == -1 { + firstOther = i + } + } + if firstOther != -1 && firstOther < lastMouseSelection { + t.Fatalf("Non-mouse_selection item %q at position %d ranks above mouse_selection item at position %d", + results[firstOther].action, firstOther+1, lastMouseSelection+1) + } + + // Every mouse_selection result must have action column highlighted (both + // "mouse" and "selection" appear in the action text). + for i, r := range results { + if !strings.HasPrefix(r.action, "mouse_selection") { + continue + } + if !r.actionMatch { + t.Fatalf("Result %d (%s): mouse_selection item must have action column highlighted", i+1, r.action) + } + } + + // mouse_handle_click also matches both "mouse" and "selection" in its action + // text, but it's a longer string so it should rank below mouse_selection items. + for i, r := range results { + if strings.HasPrefix(r.action, "mouse_handle_click") { + if i < lastMouseSelection { + t.Fatalf("Result %d (%s): should rank below all mouse_selection variants (last at %d)", + i+1, r.action, lastMouseSelection+1) + } + } + } +} + +func TestQueryRankingMouseSelectionSingleToken(t *testing.T) { + h := newMouseTestHandler() + results := queryResults(h, "mouse") + if len(results) == 0 { + t.Fatal("Expected results for 'mouse'") + } + // Bare "mouse_selection" (shortest action with "mouse") should be first + if results[0].action != "mouse_selection" { + t.Fatalf("Expected bare 'mouse_selection' first, got %q", results[0].action) + } + // Items matching only via category (paste_selection, paste_from_selection) + // should rank below items matching "mouse" in the action column. + lastActionMatch := -1 + for i, r := range results { + if r.actionMatch { + lastActionMatch = i + } + } + for i, r := range results { + if !r.actionMatch && r.categoryMatch && i < lastActionMatch { + t.Fatalf("Result %d (%s): category-only match should rank below action matches", i+1, r.action) + } + } +} + +func TestQueryRankingShorterMatchFirst(t *testing.T) { + h := newMouseTestHandler() + // "mouse_selection normal" (shorter) should rank above "mouse_selection rectangle" (longer) + // when both match equally well + top := topActions(h, "mouse_selection normal", 1) + if len(top) == 0 { + t.Fatal("Expected results") + } + if top[0] != "mouse_selection normal" { + t.Fatalf("Expected 'mouse_selection normal' first, got %q", top[0]) + } +} + +func TestQueryMatchInfoColumns(t *testing.T) { + // Verify match_infos correctly tracks positions in all 3 columns: key, action, category. + h := newMultiTokenTestHandler() + + // "ctrl clipboard" — "ctrl" matches key column (ctrl+shift+c), "clipboard" matches action + h.query = "ctrl clipboard" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'ctrl clipboard'") + } + + // Find copy_to_clipboard in results + for fi, idx := range h.filtered_idx { + if h.all_items[idx].binding.ActionDisplay != "copy_to_clipboard" { + continue + } + mi := h.match_infos[fi] + // Key column (col 0) should have positions for "ctrl" + if len(mi.keyPositions) == 0 { + t.Fatal("copy_to_clipboard: expected match positions in key column for 'ctrl'") + } + // Action column (col 1) should have positions for "clipboard" + if len(mi.actionPositions) == 0 { + t.Fatal("copy_to_clipboard: expected match positions in action column for 'clipboard'") + } + return + } + t.Fatal("Expected copy_to_clipboard in results") +} + +func TestQueryMatchInfoCategoryColumn(t *testing.T) { + // Verify the category column (col 2) gets match positions when a token matches it. + h := newMultiTokenTestHandler() + + // "tab close" — "tab" matches category "Tab management", "close" matches action close_tab + h.query = "tab close" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'tab close'") + } + for fi, idx := range h.filtered_idx { + if h.all_items[idx].binding.ActionDisplay != "close_tab" { + continue + } + mi := h.match_infos[fi] + // Action column (col 1) should have positions for "close" and/or "tab" + if len(mi.actionPositions) == 0 { + t.Fatal("close_tab: expected match positions in action column") + } + // Category column (col 2) should have positions for "tab" in "Tab management" + if len(mi.categoryPositions) == 0 { + t.Fatal("close_tab: expected match positions in category column for 'tab'") + } + return + } + t.Fatal("Expected close_tab in results") +} + +func TestQueryMatchInfoKeyColumn(t *testing.T) { + // Verify the key column (col 0) gets match positions when searching by key binding. + h := newMouseTestHandler() + + // "left press" — matches key column for mouse bindings + h.query = "left press" + h.updateFilter() + if len(h.filtered_idx) == 0 { + t.Fatal("Expected matches for 'left press'") + } + // At least one result should have positions in the key column + foundKeyMatch := false + for fi := range h.filtered_idx { + mi := h.match_infos[fi] + if len(mi.keyPositions) > 0 { + foundKeyMatch = true + break + } + } + if !foundKeyMatch { + t.Fatal("Expected at least one result with match positions in key column for 'left press'") + } +} + +func TestQueryRankingShorterActionFirst(t *testing.T) { + // When 2 tokens both match in the action column of item A, + // A should rank above item B that also matches both tokens but has a + // longer action string. This verifies that shorter matches are preferred. + h := newBuilder(). + addBinding("", "Window management", testBinding("ctrl+n", "new_window", "Open a new window")). + addBinding("", "Window management", testBinding("ctrl+w", "close_active", "Close the active pane")). + addBinding("", "Miscellaneous", testBinding("ctrl+shift+n", "new_os_window", "Open new OS window")). + build() + + // "new window" — both tokens match new_window's action coherently, + // while new_os_window also matches but is longer. + top := topActions(h, "new window", 2) + if len(top) < 2 { + t.Fatalf("Expected at least 2 results, got %d", len(top)) + } + // new_window should beat new_os_window (shorter action string) + if top[0] != "new_window" { + t.Fatalf("Expected new_window first (shorter match), got %q", top[0]) + } +} + +func TestQueryRankingCrossColumnVsCategoryOnly(t *testing.T) { + // An item matching tokens across key+action columns should rank above + // an item that only matches via the category column. + h := newBuilder(). + addBinding("", "Scrolling", testBinding("ctrl+shift+up", "scroll_line_up", "Scroll up")). + addBinding("", "Scrolling", testBinding("page_up", "scroll_page_up", "Scroll one page up")). + addBinding("", "Scroll buffer", Binding{ + Key: "ctrl+l", Action: "clear_terminal", + ActionDisplay: "clear_terminal reset active", + Definition: "clear_terminal reset active", Help: "Clear screen", + }). + build() + + // "scroll up" — scroll_line_up and scroll_page_up match both tokens in action; + // clear_terminal only matches "scroll" via its category "Scroll buffer". + top := topActions(h, "scroll up", 3) + if len(top) < 2 { + t.Fatalf("Expected at least 2 results, got %d", len(top)) + } + // Both scroll_*_up actions should rank above clear_terminal + for i, action := range top[:2] { + if !strings.Contains(action, "scroll") || !strings.Contains(action, "up") { + t.Fatalf("Result %d: expected scroll_*_up variant, got %q", i+1, action) + } + } + // If clear_terminal appears, it should be last + for i, action := range top { + if action == "clear_terminal" && i < 2 { + t.Fatalf("clear_terminal (category-only match) should rank below action matches, but got position %d", i+1) + } + } +}