parent
f13c8cd44d
commit
5638d20921
5 changed files with 168 additions and 84 deletions
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/kovidgoyal/kitty/tools/tty"
|
||||
"github.com/kovidgoyal/kitty/tools/tui"
|
||||
"github.com/kovidgoyal/kitty/tools/tui/loop"
|
||||
"github.com/kovidgoyal/kitty/tools/utils"
|
||||
"github.com/kovidgoyal/kitty/tools/wcswidth"
|
||||
)
|
||||
|
||||
|
|
@ -83,6 +84,11 @@ func truncateToWidth(s string, maxWidth int) string {
|
|||
return string(runes) + "..."
|
||||
}
|
||||
|
||||
// CachedSettings holds persistent UI settings stored in command-palette.json.
|
||||
type CachedSettings struct {
|
||||
ShowUnmapped bool `json:"show_unmapped"`
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
lp *loop.Loop
|
||||
screen_size loop.ScreenSize
|
||||
|
|
@ -98,6 +104,8 @@ type Handler struct {
|
|||
display_lines []displayLine
|
||||
results_start_y int
|
||||
results_height int
|
||||
show_unmapped bool
|
||||
cv *utils.CachedValues[*CachedSettings]
|
||||
}
|
||||
|
||||
func (h *Handler) initialize() (string, error) {
|
||||
|
|
@ -111,6 +119,12 @@ func (h *Handler) initialize() (string, error) {
|
|||
h.lp.AllowLineWrapping(false)
|
||||
h.lp.SetWindowTitle("Command Palette")
|
||||
|
||||
// Initialize with ShowUnmapped: true as the default; Load() returns this
|
||||
// default when no cache file exists yet.
|
||||
h.cv = utils.NewCachedValues("command-palette", &CachedSettings{ShowUnmapped: true})
|
||||
settings := h.cv.Load()
|
||||
h.show_unmapped = settings.ShowUnmapped
|
||||
|
||||
if err := h.loadData(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -211,10 +225,13 @@ func (h *Handler) flattenBindings() {
|
|||
|
||||
func (h *Handler) updateFilter() {
|
||||
if h.query == "" {
|
||||
// Show all items in original order
|
||||
h.filtered_idx = make([]int, len(h.all_items))
|
||||
for i := range h.all_items {
|
||||
h.filtered_idx[i] = i
|
||||
// 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 {
|
||||
if !h.show_unmapped && item.binding.Key == "" {
|
||||
continue
|
||||
}
|
||||
h.filtered_idx = append(h.filtered_idx, i)
|
||||
}
|
||||
h.match_infos = nil
|
||||
h.selected_idx = 0
|
||||
|
|
@ -253,6 +270,9 @@ func (h *Handler) updateFilter() {
|
|||
}
|
||||
var matches []scored
|
||||
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
|
||||
|
|
@ -378,9 +398,14 @@ func (h *Handler) draw_screen() {
|
|||
|
||||
// Draw key hints footer
|
||||
h.lp.MoveCursorTo(1, hintsY)
|
||||
unmappedToggleLabel := "Show"
|
||||
if h.show_unmapped {
|
||||
unmappedToggleLabel = "Hide"
|
||||
}
|
||||
footer := h.lp.SprintStyled("fg=bright-yellow", "[Enter]") + " Run " +
|
||||
h.lp.SprintStyled("fg=bright-yellow", "[Esc]") + " Quit " +
|
||||
h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate"
|
||||
h.lp.SprintStyled("fg=bright-yellow", "\u2191\u2193") + " Navigate " +
|
||||
h.lp.SprintStyled("fg=bright-yellow", "[F12]") + " " + unmappedToggleLabel + " unmapped"
|
||||
matchInfo := ""
|
||||
if h.query != "" {
|
||||
matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
|
||||
|
|
@ -716,6 +741,17 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error {
|
|||
}
|
||||
return nil
|
||||
}
|
||||
if ev.MatchesPressOrRepeat("f12") {
|
||||
ev.Handled = true
|
||||
h.show_unmapped = !h.show_unmapped
|
||||
if h.cv != nil {
|
||||
h.cv.Opts.ShowUnmapped = h.show_unmapped
|
||||
h.cv.Save()
|
||||
}
|
||||
h.updateFilter()
|
||||
h.draw_screen()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
from ..tui.handler import result_handler
|
||||
|
||||
|
||||
def collect_keys_data(opts: Any, show_unmapped: bool = False) -> dict[str, Any]:
|
||||
def collect_keys_data(opts: Any) -> dict[str, Any]:
|
||||
"""Collect all keybinding data from options into a JSON-serializable dict."""
|
||||
from kitty.actions import get_all_actions, groups
|
||||
from kitty.options.utils import KeyDefinition
|
||||
|
|
@ -97,33 +97,43 @@ def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut:
|
|||
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'])
|
||||
# Add unmapped actions (actions with no keyboard shortcut).
|
||||
# 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,
|
||||
})
|
||||
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']))
|
||||
# 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']))
|
||||
|
||||
# Re-order default_mode_cats by groups ordering (adding unmapped actions may
|
||||
# have appended new categories at the end, breaking the established order).
|
||||
reordered: dict[str, list[dict[str, str]]] = {}
|
||||
for group_title in groups.values():
|
||||
if group_title in default_mode_cats:
|
||||
reordered[group_title] = default_mode_cats[group_title]
|
||||
for cat_name, binds in default_mode_cats.items():
|
||||
if cat_name not in reordered:
|
||||
reordered[cat_name] = binds
|
||||
modes[''] = reordered
|
||||
|
||||
# Emit explicit mode and category ordering since JSON maps lose insertion order
|
||||
mode_order = list(modes.keys())
|
||||
|
|
@ -160,9 +170,6 @@ 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 = r'''
|
||||
--show-unmapped
|
||||
type=bool-set
|
||||
Also show actions that have not been mapped to a keyboard shortcut.
|
||||
'''.format
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -130,10 +130,11 @@ func TestMouseBindingsMarkedCorrectly(t *testing.T) {
|
|||
|
||||
func TestFilterNoQueryReturnsAll(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.show_unmapped = true // show all items including unmapped
|
||||
h.query = ""
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) != len(h.all_items) {
|
||||
t.Fatalf("With no query, expected %d items, got %d", len(h.all_items), len(h.filtered_idx))
|
||||
t.Fatalf("With no query and show_unmapped=true, expected %d items, got %d", len(h.all_items), len(h.filtered_idx))
|
||||
}
|
||||
for i, idx := range h.filtered_idx {
|
||||
if idx != i {
|
||||
|
|
@ -670,10 +671,77 @@ func TestUnmappedActionDisplayed(t *testing.T) {
|
|||
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
|
||||
|
||||
// With show_unmapped=true, unmapped action should be searchable
|
||||
h.show_unmapped = true
|
||||
h.query = "scroll_home"
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) == 0 {
|
||||
t.Fatal("Expected unmapped action to be found by action name search")
|
||||
t.Fatal("Expected unmapped action to be found by action name search when show_unmapped=true")
|
||||
}
|
||||
|
||||
// With show_unmapped=false, unmapped action should be hidden
|
||||
h.show_unmapped = false
|
||||
h.query = ""
|
||||
h.updateFilter()
|
||||
for _, idx := range h.filtered_idx {
|
||||
if h.all_items[idx].binding.Key == "" {
|
||||
t.Fatal("Expected unmapped action to be hidden when show_unmapped=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
if len(h.all_items) != 2 {
|
||||
t.Fatalf("Expected 2 items in all_items, got %d", len(h.all_items))
|
||||
}
|
||||
|
||||
// With show_unmapped=false, only mapped items should appear
|
||||
h.show_unmapped = false
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) != 1 {
|
||||
t.Fatalf("With show_unmapped=false, expected 1 item, got %d", len(h.filtered_idx))
|
||||
}
|
||||
if h.all_items[h.filtered_idx[0]].binding.Key == "" {
|
||||
t.Fatal("Filtered item should not be unmapped when show_unmapped=false")
|
||||
}
|
||||
|
||||
// With show_unmapped=true, both items should appear
|
||||
h.show_unmapped = true
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) != 2 {
|
||||
t.Fatalf("With show_unmapped=true, expected 2 items, got %d", len(h.filtered_idx))
|
||||
}
|
||||
|
||||
// Toggle back to false with a query active; unmapped should still be hidden
|
||||
h.show_unmapped = false
|
||||
h.query = "paste"
|
||||
h.updateFilter()
|
||||
for _, idx := range h.filtered_idx {
|
||||
if h.all_items[idx].binding.Key == "" {
|
||||
t.Fatal("Unmapped item should not appear in search results when show_unmapped=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2304,18 +2304,10 @@ def input_unicode_character(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:
|
||||
def command_palette(self) -> None:
|
||||
from kittens.command_palette.main import collect_keys_data
|
||||
show_unmapped = 'show_unmapped' in args
|
||||
data = collect_keys_data(get_options(), show_unmapped=show_unmapped)
|
||||
data = collect_keys_data(get_options())
|
||||
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
|
||||
|
||||
@ac(
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ def test_collect_keys_data(self):
|
|||
self.assertIn('long_help', b)
|
||||
self.assertIsInstance(b['key'], str)
|
||||
self.assertIsInstance(b['action'], str)
|
||||
self.assertTrue(len(b['key']) > 0)
|
||||
# key may be empty for unmapped actions; action must always be non-empty
|
||||
self.assertTrue(len(b['action']) > 0)
|
||||
# Mouse mappings
|
||||
self.assertIsInstance(data['mouse'], list)
|
||||
|
|
@ -61,9 +61,17 @@ def test_collect_keys_bindings_sorted(self):
|
|||
from kittens.command_palette.main import collect_keys_data
|
||||
opts = self.set_options()
|
||||
data = collect_keys_data(opts)
|
||||
# Within each category, mapped entries (non-empty key) come first sorted by key,
|
||||
# then unmapped entries (empty key) sorted by action name.
|
||||
for cat_name, bindings in data['modes'][''].items():
|
||||
keys = [b['key'] for b in bindings]
|
||||
self.ae(keys, sorted(keys), f'Bindings in {cat_name} should be sorted by key')
|
||||
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'
|
||||
)
|
||||
|
||||
def test_collect_keys_has_help_text(self):
|
||||
from kittens.command_palette.main import collect_keys_data
|
||||
|
|
@ -99,28 +107,13 @@ def test_ordering_arrays_present(self):
|
|||
f'category_order for mode {mode_name!r} should match modes keys',
|
||||
)
|
||||
|
||||
def test_show_unmapped_includes_extra_actions(self):
|
||||
def test_always_includes_unmapped_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
|
||||
data = collect_keys_data(opts)
|
||||
# Unmapped actions (empty key) are always included
|
||||
found_unmapped = False
|
||||
for cats in data_unmapped['modes'].values():
|
||||
for cats in data['modes'].values():
|
||||
for bindings in cats.values():
|
||||
for b in bindings:
|
||||
if b['key'] == '':
|
||||
|
|
@ -129,24 +122,12 @@ def count_bindings(data: dict) -> int:
|
|||
self.assertTrue(len(b['action']) > 0)
|
||||
self.assertTrue(len(b['definition']) > 0)
|
||||
break
|
||||
self.assertTrue(found_unmapped, 'Expected at least one unmapped action')
|
||||
self.assertTrue(found_unmapped, 'Expected at least one unmapped action to always be present')
|
||||
|
||||
def test_show_unmapped_false_has_no_empty_keys(self):
|
||||
def test_unmapped_actions_sorted_order(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)
|
||||
data = collect_keys_data(opts)
|
||||
# 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue