mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-06 09:15:57 +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"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
binding Binding
|
||||
searchText string // key + action_display + category for FZF
|
||||
binding Binding
|
||||
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 {
|
||||
@@ -53,6 +59,9 @@ type displayLine struct {
|
||||
|
||||
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
|
||||
// truncated and maxWidth > 3. When maxWidth <= 3, the string is simply trimmed
|
||||
// to fit without appending ellipsis (no room for it).
|
||||
@@ -78,9 +87,9 @@ type Handler struct {
|
||||
lp *loop.Loop
|
||||
screen_size loop.ScreenSize
|
||||
all_items []DisplayItem
|
||||
search_texts []string // parallel to all_items, for FZF scoring
|
||||
matcher *fzf.FuzzyMatcher
|
||||
filtered_idx []int // indices into all_items for current results
|
||||
filtered_idx []int // indices into all_items for current results
|
||||
match_infos []matchInfo // parallel to filtered_idx, valid when query != ""
|
||||
query string
|
||||
selected_idx int
|
||||
scroll_offset int
|
||||
@@ -176,13 +185,13 @@ func (h *Handler) flattenBindings() {
|
||||
b.Category = catName
|
||||
b.Mode = modeName
|
||||
b.IsMouse = false
|
||||
searchText := b.Key + " " + b.ActionDisplay + " " + catName
|
||||
if modeName != "" {
|
||||
searchText += " " + modeName
|
||||
keyText := b.Key
|
||||
if keyText == "" {
|
||||
keyText = unmappedLabel
|
||||
}
|
||||
h.all_items = append(h.all_items, DisplayItem{
|
||||
binding: b,
|
||||
searchText: searchText,
|
||||
binding: b,
|
||||
colTexts: [3]string{keyText, b.ActionDisplay, catName},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -193,18 +202,11 @@ func (h *Handler) flattenBindings() {
|
||||
b.Category = "Mouse actions"
|
||||
b.Mode = ""
|
||||
b.IsMouse = true
|
||||
searchText := b.Key + " " + b.ActionDisplay + " Mouse"
|
||||
h.all_items = append(h.all_items, DisplayItem{
|
||||
binding: b,
|
||||
searchText: searchText,
|
||||
binding: b,
|
||||
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() {
|
||||
@@ -214,34 +216,99 @@ func (h *Handler) updateFilter() {
|
||||
for i := range h.all_items {
|
||||
h.filtered_idx[i] = i
|
||||
}
|
||||
} else {
|
||||
results, err := h.matcher.Score(h.search_texts, h.query)
|
||||
if err != nil {
|
||||
h.filtered_idx = nil
|
||||
return
|
||||
h.match_infos = nil
|
||||
h.selected_idx = 0
|
||||
h.scroll_offset = 0
|
||||
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
|
||||
}
|
||||
var matches []scored
|
||||
for i, r := range results {
|
||||
if r.Score > 0 {
|
||||
matches = append(matches, scored{idx: i, score: r.Score})
|
||||
}
|
||||
|
||||
type scored struct {
|
||||
idx int
|
||||
score uint
|
||||
colIdx int
|
||||
positions []int
|
||||
}
|
||||
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 {
|
||||
return matches[i].score > matches[j].score
|
||||
})
|
||||
h.filtered_idx = make([]int, len(matches))
|
||||
for i, m := range matches {
|
||||
h.filtered_idx[i] = m.idx
|
||||
if bestScore > 0 {
|
||||
matches = append(matches, scored{idx: i, score: bestScore, colIdx: bestCol, positions: bestPositions})
|
||||
}
|
||||
}
|
||||
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.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 {
|
||||
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
|
||||
return nil
|
||||
@@ -368,8 +435,12 @@ func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
|
||||
})
|
||||
}
|
||||
|
||||
// Binding line
|
||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
||||
// Binding line — key column shows "(unmapped)" for actions with no shortcut
|
||||
keyDisplay := b.Key
|
||||
if keyDisplay == "" {
|
||||
keyDisplay = unmappedLabel
|
||||
}
|
||||
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
|
||||
lines = append(lines, displayLine{
|
||||
text: fmt.Sprintf(" %-*s %s", maxKeyDisplayWidth, keyDisplay, b.ActionDisplay),
|
||||
itemIdx: fi,
|
||||
@@ -381,11 +452,22 @@ func (h *Handler) drawGroupedResults(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
|
||||
for fi, idx := range h.filtered_idx {
|
||||
item := &h.all_items[idx]
|
||||
b := &item.binding
|
||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
||||
keyDisplay := b.Key
|
||||
if keyDisplay == "" {
|
||||
keyDisplay = unmappedLabel
|
||||
}
|
||||
keyDisplay = truncateToWidth(keyDisplay, maxKeyDisplayWidth)
|
||||
catSuffix := ""
|
||||
if b.Mode != "" {
|
||||
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
|
||||
|
||||
// Style the key portion green, leave action unstyled
|
||||
keyDisplay := truncateToWidth(b.Key, maxKeyDisplayWidth)
|
||||
keyPrefix := fmt.Sprintf(" %-*s", maxKeyDisplayWidth, keyDisplay)
|
||||
rest := ""
|
||||
if len(text) > len(keyPrefix) {
|
||||
rest = text[len(keyPrefix):]
|
||||
// Build the key display (using unmappedLabel for items with no shortcut)
|
||||
rawKey := b.Key
|
||||
if rawKey == "" {
|
||||
rawKey = unmappedLabel
|
||||
}
|
||||
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
|
||||
|
||||
@@ -10,7 +10,7 @@ from kitty.typing_compat import BossType
|
||||
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."""
|
||||
from kitty.actions import get_all_actions, groups
|
||||
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
|
||||
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
|
||||
mode_order = list(modes.keys())
|
||||
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'
|
||||
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__':
|
||||
|
||||
@@ -152,13 +152,17 @@ func TestFilterMatchesSubset(t *testing.T) {
|
||||
if len(h.filtered_idx) >= len(h.all_items) {
|
||||
t.Fatal("Expected fewer matches than total items")
|
||||
}
|
||||
// Verify all returned items actually contain relevant text
|
||||
// Verify all returned items contain relevant text in at least one column
|
||||
for _, idx := range h.filtered_idx {
|
||||
text := strings.ToLower(h.all_items[idx].searchText)
|
||||
if !strings.Contains(text, "clipboard") {
|
||||
// FZF does fuzzy matching, so this is a soft check —
|
||||
// the characters should at least be present
|
||||
item := &h.all_items[idx]
|
||||
found := false
|
||||
for _, col := range item.colTexts {
|
||||
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) {
|
||||
h := newTestHandler()
|
||||
for i, item := range h.all_items {
|
||||
if !strings.Contains(item.searchText, item.binding.Key) {
|
||||
t.Fatalf("Item %d: search text %q should contain key %q", i, item.searchText, item.binding.Key)
|
||||
// colTexts[0] = key (or unmappedLabel for empty key), [1] = action_display, [2] = category
|
||||
expectedKey := item.binding.Key
|
||||
if expectedKey == "" {
|
||||
expectedKey = unmappedLabel
|
||||
}
|
||||
if !strings.Contains(item.searchText, item.binding.ActionDisplay) {
|
||||
t.Fatalf("Item %d: search text %q should contain action %q", i, item.searchText, item.binding.ActionDisplay)
|
||||
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.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)
|
||||
}
|
||||
}
|
||||
|
||||
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:
|
||||
self.run_kitten_with_metadata('unicode_input', window=self.window_for_dispatch)
|
||||
|
||||
@ac('misc', 'Browse and trigger keyboard shortcuts and actions in a searchable overlay')
|
||||
def command_palette(self) -> None:
|
||||
@ac('misc', '''
|
||||
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
|
||||
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)
|
||||
|
||||
@ac(
|
||||
|
||||
@@ -167,6 +167,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType:
|
||||
@func_with_args(
|
||||
'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',
|
||||
'command_palette',
|
||||
)
|
||||
def simple_parse(func: str, rest: str) -> FuncArgsType:
|
||||
return func, (rest,)
|
||||
|
||||
@@ -98,3 +98,62 @@ class TestCommandPalette(BaseTest):
|
||||
set(data['modes'][mode_name].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