mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-08 14:18:26 +02:00
Allow optionally showing unmapped actions in the command palette
Also highlight letters matching the search query. Fixes #9589
This commit is contained in:
committed by
Kovid Goyal
parent
a7480370a4
commit
f13c8cd44d
@@ -38,10 +38,16 @@ type InputData struct {
|
|||||||
CategoryOrder map[string][]string `json:"category_order"`
|
CategoryOrder map[string][]string `json:"category_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DisplayItem wraps a binding with its search text for FZF scoring
|
// DisplayItem wraps a binding with its per-column search texts for FZF scoring
|
||||||
type DisplayItem struct {
|
type DisplayItem struct {
|
||||||
binding Binding
|
binding Binding
|
||||||
searchText string // key + action_display + category for FZF
|
colTexts [3]string // [0]=key, [1]=action_display, [2]=category
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchInfo stores which column matched and the matched character positions
|
||||||
|
type matchInfo struct {
|
||||||
|
colIdx int // which column matched: 0=key, 1=action_display, 2=category
|
||||||
|
positions []int // rune positions in the matched column text
|
||||||
}
|
}
|
||||||
|
|
||||||
type displayLine struct {
|
type displayLine struct {
|
||||||
@@ -53,6 +59,9 @@ type displayLine struct {
|
|||||||
|
|
||||||
const maxKeyDisplayWidth = 30
|
const maxKeyDisplayWidth = 30
|
||||||
|
|
||||||
|
// unmappedLabel is shown in the key column for actions with no keyboard shortcut.
|
||||||
|
const unmappedLabel = "(unmapped)"
|
||||||
|
|
||||||
// truncateToWidth truncates s to fit within maxWidth cells, appending "..." if
|
// truncateToWidth truncates s to fit within maxWidth cells, appending "..." if
|
||||||
// truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed
|
// truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed
|
||||||
// to fit without appending ellipsis (no room for it).
|
// to fit without appending ellipsis (no room for it).
|
||||||
@@ -78,9 +87,9 @@ type Handler struct {
|
|||||||
lp *loop.Loop
|
lp *loop.Loop
|
||||||
screen_size loop.ScreenSize
|
screen_size loop.ScreenSize
|
||||||
all_items []DisplayItem
|
all_items []DisplayItem
|
||||||
search_texts []string // parallel to all_items, for FZF scoring
|
|
||||||
matcher *fzf.FuzzyMatcher
|
matcher *fzf.FuzzyMatcher
|
||||||
filtered_idx []int // indices into all_items for current results
|
filtered_idx []int // indices into all_items for current results
|
||||||
|
match_infos []matchInfo // parallel to filtered_idx, valid when query != ""
|
||||||
query string
|
query string
|
||||||
selected_idx int
|
selected_idx int
|
||||||
scroll_offset int
|
scroll_offset int
|
||||||
@@ -176,13 +185,13 @@ func (h *Handler) flattenBindings() {
|
|||||||
b.Category = catName
|
b.Category = catName
|
||||||
b.Mode = modeName
|
b.Mode = modeName
|
||||||
b.IsMouse = false
|
b.IsMouse = false
|
||||||
searchText := b.Key + " " + b.ActionDisplay + " " + catName
|
keyText := b.Key
|
||||||
if modeName != "" {
|
if keyText == "" {
|
||||||
searchText += " " + modeName
|
keyText = unmappedLabel
|
||||||
}
|
}
|
||||||
h.all_items = append(h.all_items, DisplayItem{
|
h.all_items = append(h.all_items, DisplayItem{
|
||||||
binding: b,
|
binding: b,
|
||||||
searchText: searchText,
|
colTexts: [3]string{keyText, b.ActionDisplay, catName},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,18 +202,11 @@ func (h *Handler) flattenBindings() {
|
|||||||
b.Category = "Mouse actions"
|
b.Category = "Mouse actions"
|
||||||
b.Mode = ""
|
b.Mode = ""
|
||||||
b.IsMouse = true
|
b.IsMouse = true
|
||||||
searchText := b.Key + " " + b.ActionDisplay + " Mouse"
|
|
||||||
h.all_items = append(h.all_items, DisplayItem{
|
h.all_items = append(h.all_items, DisplayItem{
|
||||||
binding: b,
|
binding: b,
|
||||||
searchText: searchText,
|
colTexts: [3]string{b.Key, b.ActionDisplay, "Mouse actions"},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build parallel search texts array for FZF
|
|
||||||
h.search_texts = make([]string, len(h.all_items))
|
|
||||||
for i, item := range h.all_items {
|
|
||||||
h.search_texts[i] = item.searchText
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) updateFilter() {
|
func (h *Handler) updateFilter() {
|
||||||
@@ -214,34 +216,99 @@ func (h *Handler) updateFilter() {
|
|||||||
for i := range h.all_items {
|
for i := range h.all_items {
|
||||||
h.filtered_idx[i] = i
|
h.filtered_idx[i] = i
|
||||||
}
|
}
|
||||||
} else {
|
h.match_infos = nil
|
||||||
results, err := h.matcher.Score(h.search_texts, h.query)
|
h.selected_idx = 0
|
||||||
if err != nil {
|
h.scroll_offset = 0
|
||||||
h.filtered_idx = nil
|
return
|
||||||
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 := 0; c < 3; c++ {
|
||||||
|
results, err := h.matcher.Score(colSlices[c], h.query)
|
||||||
|
if err == nil {
|
||||||
|
colResults[c] = results
|
||||||
}
|
}
|
||||||
type scored struct {
|
}
|
||||||
idx int
|
|
||||||
score uint
|
type scored struct {
|
||||||
}
|
idx int
|
||||||
var matches []scored
|
score uint
|
||||||
for i, r := range results {
|
colIdx int
|
||||||
if r.Score > 0 {
|
positions []int
|
||||||
matches = append(matches, scored{idx: i, score: r.Score})
|
}
|
||||||
|
var matches []scored
|
||||||
|
for i := range h.all_items {
|
||||||
|
bestScore := uint(0)
|
||||||
|
bestCol := 0
|
||||||
|
var bestPositions []int
|
||||||
|
for c := 0; c < 3; c++ {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(matches, func(i, j int) bool {
|
if bestScore > 0 {
|
||||||
return matches[i].score > matches[j].score
|
matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions})
|
||||||
})
|
|
||||||
h.filtered_idx = make([]int, len(matches))
|
|
||||||
for i, m := range matches {
|
|
||||||
h.filtered_idx[i] = m.idx
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sort.Slice(matches, func(i, j int) bool {
|
||||||
|
return matches[i].score > matches[j].score
|
||||||
|
})
|
||||||
|
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.selected_idx = 0
|
h.selected_idx = 0
|
||||||
h.scroll_offset = 0
|
h.scroll_offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// highlightMatchedChars returns a string with characters at the given rune
|
||||||
|
// positions rendered using matchStyle, and the rest rendered using baseStyle
|
||||||
|
// (or unstyled if baseStyle is empty).
|
||||||
|
func (h *Handler) highlightMatchedChars(text string, positions []int, baseStyle, matchStyle string) string {
|
||||||
|
if len(positions) == 0 {
|
||||||
|
if baseStyle != "" {
|
||||||
|
return h.lp.SprintStyled(baseStyle, text)
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
posSet := make(map[int]bool, len(positions))
|
||||||
|
for _, p := range positions {
|
||||||
|
posSet[p] = true
|
||||||
|
}
|
||||||
|
runes := []rune(text)
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, r := range runes {
|
||||||
|
ch := string(r)
|
||||||
|
if posSet[i] {
|
||||||
|
sb.WriteString(h.lp.SprintStyled(matchStyle, ch))
|
||||||
|
} else if baseStyle != "" {
|
||||||
|
sb.WriteString(h.lp.SprintStyled(baseStyle, ch))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) selectedBinding() *Binding {
|
func (h *Handler) selectedBinding() *Binding {
|
||||||
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
|
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
|
||||||
return nil
|
return nil
|
||||||
@@ -368,8 +435,12 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding line
|
// Binding line — key column shows "(unmapped)" for actions with no shortcut
|
||||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
keyDisplay := b.Key
|
||||||
|
if keyDisplay == "" {
|
||||||
|
keyDisplay = unmappedLabel
|
||||||
|
}
|
||||||
|
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
|
||||||
lines = append(lines, displayLine{
|
lines = append(lines, displayLine{
|
||||||
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay),
|
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay),
|
||||||
itemIdx: fi,
|
itemIdx: fi,
|
||||||
@@ -381,11 +452,22 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) drawFlatResults(startY, maxRows, width int) {
|
func (h *Handler) drawFlatResults(startY, maxRows, width int) {
|
||||||
|
if len(h.filtered_idx) == 0 {
|
||||||
|
h.lp.MoveCursorTo(1, startY)
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("italic dim", " No matches found"))
|
||||||
|
h.display_lines = []displayLine{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var lines []displayLine
|
var lines []displayLine
|
||||||
for fi, idx := range h.filtered_idx {
|
for fi, idx := range h.filtered_idx {
|
||||||
item := &h.all_items[idx]
|
item := &h.all_items[idx]
|
||||||
b := &item.binding
|
b := &item.binding
|
||||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
keyDisplay := b.Key
|
||||||
|
if keyDisplay == "" {
|
||||||
|
keyDisplay = unmappedLabel
|
||||||
|
}
|
||||||
|
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
|
||||||
catSuffix := ""
|
catSuffix := ""
|
||||||
if b.Mode != "" {
|
if b.Mode != "" {
|
||||||
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
|
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
|
||||||
@@ -473,15 +555,64 @@ func (h *Handler) drawBindingLine(text string, filteredIdx, width int) {
|
|||||||
}
|
}
|
||||||
b := &h.all_items[idx].binding
|
b := &h.all_items[idx].binding
|
||||||
|
|
||||||
// Style the key portion green, leave action unstyled
|
// Build the key display (using unmappedLabel for items with no shortcut)
|
||||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
rawKey := b.Key
|
||||||
keyPrefix := fmt.Sprintf(" %-*s", maxKeyDisplayWidth, keyDisplay)
|
if rawKey == "" {
|
||||||
rest := ""
|
rawKey = unmappedLabel
|
||||||
if len(text) > len(keyPrefix) {
|
}
|
||||||
rest = text[len(keyPrefix):]
|
keyDisplay := truncateToWidth(rawKey, maxKeyDisplayWidth)
|
||||||
|
|
||||||
|
// Determine match info for highlighting (only set when a query is active)
|
||||||
|
var mi *matchInfo
|
||||||
|
if h.query != "" && filteredIdx < len(h.match_infos) {
|
||||||
|
mi = &h.match_infos[filteredIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchStyle = "fg=bright-yellow"
|
||||||
|
const keyStyle = "fg=green"
|
||||||
|
const unmappedStyle = "dim fg=green"
|
||||||
|
|
||||||
|
// 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)+" "))
|
||||||
|
} else {
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled(keyStyle, " "+keyDisplay+strings.Repeat(" ", paddingLen)+" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render action display column
|
||||||
|
if mi != nil && mi.colIdx == 1 {
|
||||||
|
h.lp.QueueWriteString(h.highlightMatchedChars(b.ActionDisplay, mi.positions, "", matchStyle))
|
||||||
|
} else {
|
||||||
|
h.lp.QueueWriteString(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("]")
|
||||||
|
} else {
|
||||||
|
if b.Mode != "" {
|
||||||
|
h.lp.QueueWriteString(fmt.Sprintf(" [%s/%s]", b.Mode, b.Category))
|
||||||
|
} else {
|
||||||
|
h.lp.QueueWriteString(fmt.Sprintf(" [%s]", b.Category))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix))
|
|
||||||
h.lp.QueueWriteString(rest)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item
|
// rowToFilteredIdx converts a 0-indexed cell Y coordinate to a filtered item
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from kitty.typing_compat import BossType
|
|||||||
from ..tui.handler import result_handler
|
from ..tui.handler import result_handler
|
||||||
|
|
||||||
|
|
||||||
def collect_keys_data(opts: Any) -> dict[str, Any]:
|
def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]:
|
||||||
"""Collect all keybinding data from options into a JSON-serializable dict."""
|
"""Collect all keybinding data from options into a JSON-serializable dict."""
|
||||||
from kitty.actions import get_all_actions, groups
|
from kitty.actions import get_all_actions, groups
|
||||||
from kitty.options.utils import KeyDefinition
|
from kitty.options.utils import KeyDefinition
|
||||||
@@ -97,6 +97,34 @@ def collect_keys_data(opts: Any) -> dict[str, Any]:
|
|||||||
new_default_cats[cat_name] = keep
|
new_default_cats[cat_name] = keep
|
||||||
modes[''] = new_default_cats
|
modes[''] = new_default_cats
|
||||||
|
|
||||||
|
# Optionally add unmapped actions (actions with no keyboard shortcut).
|
||||||
|
if show_unmapped:
|
||||||
|
# Collect all action names that already appear in a binding.
|
||||||
|
mapped_actions: set[str] = set()
|
||||||
|
for mode_cats in modes.values():
|
||||||
|
for bindings in mode_cats.values():
|
||||||
|
for b in bindings:
|
||||||
|
mapped_actions.add(b['action'])
|
||||||
|
|
||||||
|
default_mode_cats = modes.setdefault('', {})
|
||||||
|
for group_key, actions in get_all_actions().items():
|
||||||
|
category = groups[group_key]
|
||||||
|
for action in actions:
|
||||||
|
if action.name not in mapped_actions:
|
||||||
|
default_mode_cats.setdefault(category, []).append({
|
||||||
|
'key': '',
|
||||||
|
'action': action.name,
|
||||||
|
'action_display': action.name,
|
||||||
|
'definition': action.name,
|
||||||
|
'help': action.short_help,
|
||||||
|
'long_help': action.long_help,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Re-sort each category: mapped entries (non-empty key) by key first,
|
||||||
|
# then unmapped entries (empty key) sorted by action name.
|
||||||
|
for cat in default_mode_cats:
|
||||||
|
default_mode_cats[cat].sort(key=lambda b: (b['key'] == '', b['key'] or b['action']))
|
||||||
|
|
||||||
# Emit explicit mode and category ordering since JSON maps lose insertion order
|
# Emit explicit mode and category ordering since JSON maps lose insertion order
|
||||||
mode_order = list(modes.keys())
|
mode_order = list(modes.keys())
|
||||||
category_order: dict[str, list[str]] = {}
|
category_order: dict[str, list[str]] = {}
|
||||||
@@ -131,7 +159,11 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int,
|
|||||||
|
|
||||||
help_text = 'Browse and trigger keyboard shortcuts and actions'
|
help_text = 'Browse and trigger keyboard shortcuts and actions'
|
||||||
usage = ''
|
usage = ''
|
||||||
OPTIONS = ''.format
|
OPTIONS = r'''
|
||||||
|
--show-unmapped
|
||||||
|
type=bool-set
|
||||||
|
Also show actions that have not been mapped to a keyboard shortcut.
|
||||||
|
'''.format
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -152,13 +152,17 @@ func TestFilterMatchesSubset(t *testing.T) {
|
|||||||
if len(h.filtered_idx) >= len(h.all_items) {
|
if len(h.filtered_idx) >= len(h.all_items) {
|
||||||
t.Fatal("Expected fewer matches than total items")
|
t.Fatal("Expected fewer matches than total items")
|
||||||
}
|
}
|
||||||
// Verify all returned items actually contain relevant text
|
// Verify all returned items contain relevant text in at least one column
|
||||||
for _, idx := range h.filtered_idx {
|
for _, idx := range h.filtered_idx {
|
||||||
text := strings.ToLower(h.all_items[idx].searchText)
|
item := &h.all_items[idx]
|
||||||
if !strings.Contains(text, "clipboard") {
|
found := false
|
||||||
// FZF does fuzzy matching, so this is a soft check —
|
for _, col := range item.colTexts {
|
||||||
// the characters should at least be present
|
if strings.Contains(strings.ToLower(col), "clipboard") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
_ = found // FZF does fuzzy matching, so this is a soft check
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,11 +238,16 @@ func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) {
|
|||||||
func TestSearchTextContainsKeyAndAction(t *testing.T) {
|
func TestSearchTextContainsKeyAndAction(t *testing.T) {
|
||||||
h := newTestHandler()
|
h := newTestHandler()
|
||||||
for i, item := range h.all_items {
|
for i, item := range h.all_items {
|
||||||
if !strings.Contains(item.searchText, item.binding.Key) {
|
// colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category
|
||||||
t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key)
|
expectedKey := item.binding.Key
|
||||||
|
if expectedKey == "" {
|
||||||
|
expectedKey = unmappedLabel
|
||||||
}
|
}
|
||||||
if !strings.Contains(item.searchText, item.binding.ActionDisplay) {
|
if !strings.Contains(item.colTexts[0], expectedKey) {
|
||||||
t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay)
|
t.Fatalf("Item %d: colTexts[0] %q should contain key %q", i, item.colTexts[0], 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,3 +558,122 @@ func TestScrollAdjustRevealsSectionHeader(t *testing.T) {
|
|||||||
t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset)
|
t.Fatalf("Expected scroll_offset=0 to show category header, got %d", h.scroll_offset)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestColTextsPopulated(t *testing.T) {
|
||||||
|
h := newTestHandler()
|
||||||
|
for i, item := range h.all_items {
|
||||||
|
if item.binding.IsMouse {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
expectedKey := item.binding.Key
|
||||||
|
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.colTexts[1] != item.binding.ActionDisplay {
|
||||||
|
t.Fatalf("Item %d: colTexts[1]=%q expected %q", i, item.colTexts[1], 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterSingleColumnMatch(t *testing.T) {
|
||||||
|
// "scroll" is in action_display column only, not in key or category.
|
||||||
|
// With per-column matching it should still match the action column.
|
||||||
|
h := newTestHandler()
|
||||||
|
h.query = "scroll"
|
||||||
|
h.updateFilter()
|
||||||
|
if len(h.filtered_idx) == 0 {
|
||||||
|
t.Fatal("Expected matches for 'scroll' against action column")
|
||||||
|
}
|
||||||
|
// 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 // FZF does fuzzy matching; at least the characters should appear in one column
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterMatchInfosParallelToFilteredIdx(t *testing.T) {
|
||||||
|
h := newTestHandler()
|
||||||
|
h.query = "clipboard"
|
||||||
|
h.updateFilter()
|
||||||
|
if len(h.filtered_idx) == 0 {
|
||||||
|
t.Fatal("Expected some matches")
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterMatchInfosNilWhenNoQuery(t *testing.T) {
|
||||||
|
h := newTestHandler()
|
||||||
|
h.query = ""
|
||||||
|
h.updateFilter()
|
||||||
|
if h.match_infos != nil {
|
||||||
|
t.Fatal("Expected match_infos to be nil when query is empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if len(h.all_items) != 2 {
|
||||||
|
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
|
||||||
|
}
|
||||||
|
// Find the unmapped item
|
||||||
|
var unmapped *DisplayItem
|
||||||
|
for i := range h.all_items {
|
||||||
|
if h.all_items[i].binding.Key == "" {
|
||||||
|
unmapped = &h.all_items[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
// Should be searchable by action name
|
||||||
|
h.query = "scroll_home"
|
||||||
|
h.updateFilter()
|
||||||
|
if len(h.filtered_idx) == 0 {
|
||||||
|
t.Fatal("Expected unmapped action to be found by action name search")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2302,10 +2302,20 @@ class Boss:
|
|||||||
def input_unicode_character(self) -> None:
|
def input_unicode_character(self) -> None:
|
||||||
self.run_kitten_with_metadata('unicode_input', window=self.window_for_dispatch)
|
self.run_kitten_with_metadata('unicode_input', window=self.window_for_dispatch)
|
||||||
|
|
||||||
@ac('misc', 'Browse and trigger keyboard shortcuts and actions in a searchable overlay')
|
@ac('misc', '''
|
||||||
def command_palette(self) -> None:
|
Browse and trigger keyboard shortcuts and actions in a searchable overlay.
|
||||||
|
|
||||||
|
Optionally pass :code:`show_unmapped` to also show actions that have no keyboard shortcut assigned::
|
||||||
|
|
||||||
|
# show only mapped actions (default)
|
||||||
|
map ctrl+shift+p command_palette
|
||||||
|
# also show unmapped actions
|
||||||
|
map ctrl+shift+alt+p command_palette show_unmapped
|
||||||
|
''')
|
||||||
|
def command_palette(self, *args: str) -> None:
|
||||||
from kittens.command_palette.main import collect_keys_data
|
from kittens.command_palette.main import collect_keys_data
|
||||||
data = collect_keys_data(get_options())
|
show_unmapped = 'show_unmapped' in args
|
||||||
|
data = collect_keys_data(get_options(), show_unmapped=show_unmapped)
|
||||||
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
|
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
|
||||||
|
|
||||||
@ac(
|
@ac(
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
|
|||||||
@func_with_args(
|
@func_with_args(
|
||||||
'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
|
'set_background_opacity', 'goto_layout', 'toggle_layout', 'toggle_tab', 'kitty_shell', 'show_kitty_doc',
|
||||||
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
|
'set_tab_title', 'push_keyboard_mode', 'dump_lines_with_attrs', 'set_window_title', 'simulate_color_scheme_preference_change',
|
||||||
|
'command_palette',
|
||||||
)
|
)
|
||||||
def simple_parse(func: str, rest: str) -> FuncArgsType:
|
def simple_parse(func: str, rest: str) -> FuncArgsType:
|
||||||
return func, (rest,)
|
return func, (rest,)
|
||||||
|
|||||||
@@ -98,3 +98,62 @@ class TestCommandPalette(BaseTest):
|
|||||||
set(data['modes'][mode_name].keys()),
|
set(data['modes'][mode_name].keys()),
|
||||||
f'category_order for mode {mode_name!r} should match modes keys',
|
f'category_order for mode {mode_name!r} should match modes keys',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_show_unmapped_includes_extra_actions(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
from kitty.actions import get_all_actions
|
||||||
|
opts = self.set_options()
|
||||||
|
data_default = collect_keys_data(opts, show_unmapped=False)
|
||||||
|
data_unmapped = collect_keys_data(opts, show_unmapped=True)
|
||||||
|
# With show_unmapped=True, we should have at least as many bindings
|
||||||
|
def count_bindings(data: dict) -> int:
|
||||||
|
total = 0
|
||||||
|
for cats in data['modes'].values():
|
||||||
|
for bindings in cats.values():
|
||||||
|
total += len(bindings)
|
||||||
|
return total
|
||||||
|
count_default = count_bindings(data_default)
|
||||||
|
count_with_unmapped = count_bindings(data_unmapped)
|
||||||
|
self.assertTrue(
|
||||||
|
count_with_unmapped >= count_default,
|
||||||
|
'show_unmapped should not remove any existing bindings',
|
||||||
|
)
|
||||||
|
# There should be at least one unmapped action (empty key) in the result
|
||||||
|
found_unmapped = False
|
||||||
|
for cats in data_unmapped['modes'].values():
|
||||||
|
for bindings in cats.values():
|
||||||
|
for b in bindings:
|
||||||
|
if b['key'] == '':
|
||||||
|
found_unmapped = True
|
||||||
|
# Unmapped actions must still have action and definition
|
||||||
|
self.assertTrue(len(b['action']) > 0)
|
||||||
|
self.assertTrue(len(b['definition']) > 0)
|
||||||
|
break
|
||||||
|
self.assertTrue(found_unmapped, 'Expected at least one unmapped action')
|
||||||
|
|
||||||
|
def test_show_unmapped_false_has_no_empty_keys(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts, show_unmapped=False)
|
||||||
|
for cats in data['modes'].values():
|
||||||
|
for bindings in cats.values():
|
||||||
|
for b in bindings:
|
||||||
|
self.assertTrue(
|
||||||
|
len(b['key']) > 0,
|
||||||
|
f'Without show_unmapped, all bindings should have non-empty keys; got {b!r}',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_show_unmapped_sorted_order(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts, show_unmapped=True)
|
||||||
|
# In each category, mapped bindings (non-empty key) should come before unmapped ones
|
||||||
|
for cat_name, bindings in data['modes'].get('', {}).items():
|
||||||
|
seen_unmapped = False
|
||||||
|
for b in bindings:
|
||||||
|
if b['key'] == '':
|
||||||
|
seen_unmapped = True
|
||||||
|
elif seen_unmapped:
|
||||||
|
self.fail(
|
||||||
|
f'In category {cat_name!r}, mapped binding {b!r} follows an unmapped one'
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user