mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-06 01:05:48 +02:00
Command palette: word-level search with multi-token cross-column matching
Implement a word-level scoring engine for the command palette that replaces the previous FZF-based approach. Query tokens are matched against pre-tokenized words in each column (key, action, category) with exact, prefix, and edit-distance scoring. Multiple search terms are supported with cross-column matching — items matching more tokens rank higher. Compound query tokens containing delimiters (e.g. mouse_selection) are matched as units. Add comprehensive tests using a Go builder API instead of raw JSON blobs, covering single-token, multi-token, partial-match, ranking, mouse binding, and unmapped action scenarios. Add documentation for the command palette kitten.
This commit is contained in:
114
docs/kittens/command-palette.rst
Normal file
114
docs/kittens/command-palette.rst
Normal file
@@ -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 <modal_mappings>` 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
|
||||
@@ -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 <kittens/command-palette>`
|
||||
Browse, search and trigger all keyboard shortcuts and actions from a
|
||||
single searchable overlay.
|
||||
|
||||
|
||||
:doc:`Quick access terminal <kittens/quick-access-terminal>`
|
||||
Get access to a quick access floating, semi-transparent kitty window
|
||||
with a single keypress.
|
||||
|
||||
BIN
docs/screenshots/command-palette.png
Normal file
BIN
docs/screenshots/command-palette.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user