mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-26 02:02:14 +02:00
Add command_palette command to display bound keys
Add a Go-based command_palette kitten that provides a searchable, interactive overlay for browsing and triggering keyboard shortcuts and actions. - New Go kitten at kittens/command_palette/ with FZF fuzzy search, grouped/flat views, and remote control action triggering - Python collect_keys_data() does data collection, passed via stdin - Navigation: arrows, ctrl+j/k (vim), ctrl+n/p (emacs), page up/down - Enter triggers the selected action via RC command - Help text displayed in footer for selected binding - Added Go tests (main_test.go) and Python tests (command_palette.py)
This commit is contained in:
309
kittens/command_palette/main_test.go
Normal file
309
kittens/command_palette/main_test.go
Normal file
@@ -0,0 +1,309 @@
|
||||
package command_palette
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"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", "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", "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", "help": "Scroll up one line", "long_help": ""},
|
||||
{"key": "ctrl+shift+down", "action": "scroll_line_down", "action_display": "scroll_line_down", "help": "Scroll down one line", "long_help": ""}
|
||||
],
|
||||
"Window management": [
|
||||
{"key": "ctrl+shift+enter", "action": "new_window", "action_display": "new_window", "help": "Open a new window", "long_help": ""}
|
||||
]
|
||||
},
|
||||
"mw": {
|
||||
"Miscellaneous": [
|
||||
{"key": "left", "action": "neighboring_window", "action_display": "neighboring_window left", "help": "Focus neighbor window", "long_help": ""},
|
||||
{"key": "esc", "action": "pop_keyboard_mode", "action_display": "pop_keyboard_mode", "help": "Pop keyboard mode", "long_help": ""}
|
||||
]
|
||||
}
|
||||
},
|
||||
"mouse": [
|
||||
{"key": "left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection normal", "help": "", "long_help": ""},
|
||||
{"key": "ctrl+left press ungrabbed", "action": "mouse_selection", "action_display": "mouse_selection rectangle", "help": "", "long_help": ""}
|
||||
],
|
||||
"mode_order": ["", "mw"],
|
||||
"category_order": {
|
||||
"": ["Copy/paste", "Scrolling", "Window management"],
|
||||
"mw": ["Miscellaneous"]
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func TestFlattenAllBindings(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
// 5 default mode + 2 mw mode + 2 mouse = 9
|
||||
if len(h.all_items) != 9 {
|
||||
t.Fatalf("Expected 9 items, got %d", len(h.all_items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultModeComesFirst(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
// First 5 items should be from default mode
|
||||
for i := 0; i < 5; i++ {
|
||||
if h.all_items[i].binding.Mode != "" {
|
||||
t.Fatalf("Item %d should be from default mode, got mode=%q", i, h.all_items[i].binding.Mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCategoryOrderPreserved(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
// Verify categories appear in the order specified by category_order
|
||||
var categories []string
|
||||
seen := map[string]bool{}
|
||||
for _, item := range h.all_items {
|
||||
if item.binding.Mode != "" || item.binding.IsMouse {
|
||||
continue
|
||||
}
|
||||
cat := item.binding.Category
|
||||
if !seen[cat] {
|
||||
categories = append(categories, cat)
|
||||
seen[cat] = true
|
||||
}
|
||||
}
|
||||
expected := []string{"Copy/paste", "Scrolling", "Window management"}
|
||||
if len(categories) != len(expected) {
|
||||
t.Fatalf("Expected %d categories, got %d: %v", len(expected), len(categories), categories)
|
||||
}
|
||||
for i, cat := range categories {
|
||||
if cat != expected[i] {
|
||||
t.Fatalf("Category %d: expected %q, got %q", i, expected[i], cat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomModePresent(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
found := false
|
||||
for _, item := range h.all_items {
|
||||
if item.binding.Mode == "mw" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Expected to find items from 'mw' mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseBindingsMarkedCorrectly(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
mouseCount := 0
|
||||
for _, item := range h.all_items {
|
||||
if item.binding.IsMouse {
|
||||
mouseCount++
|
||||
if item.binding.Category != "Mouse actions" {
|
||||
t.Fatalf("Mouse binding should have category 'Mouse actions', got %q", item.binding.Category)
|
||||
}
|
||||
}
|
||||
}
|
||||
if mouseCount != 2 {
|
||||
t.Fatalf("Expected 2 mouse bindings, got %d", mouseCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterNoQueryReturnsAll(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
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))
|
||||
}
|
||||
for i, idx := range h.filtered_idx {
|
||||
if idx != i {
|
||||
t.Fatalf("Expected sequential order, got index %d at position %d", idx, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMatchesSubset(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.query = "clipboard"
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) == 0 {
|
||||
t.Fatal("Expected matches for 'clipboard'")
|
||||
}
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterNonsenseReturnsEmpty(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.query = "zzzznonexistent"
|
||||
h.updateFilter()
|
||||
if len(h.filtered_idx) != 0 {
|
||||
t.Fatalf("Expected no matches for nonsense, got %d", len(h.filtered_idx))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterResetsSelectionAndScroll(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.query = ""
|
||||
h.updateFilter()
|
||||
h.selected_idx = 3
|
||||
h.scroll_offset = 5
|
||||
|
||||
h.query = "scroll"
|
||||
h.updateFilter()
|
||||
if h.selected_idx != 0 {
|
||||
t.Fatalf("Expected selection reset to 0, got %d", h.selected_idx)
|
||||
}
|
||||
if h.scroll_offset != 0 {
|
||||
t.Fatalf("Expected scroll offset reset to 0, got %d", h.scroll_offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedBindingValid(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.updateFilter()
|
||||
|
||||
b := h.selectedBinding()
|
||||
if b == nil {
|
||||
t.Fatal("Expected non-nil binding")
|
||||
}
|
||||
if b.Key == "" || b.Action == "" {
|
||||
t.Fatal("Binding should have non-empty key and action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedBindingNilWhenEmpty(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.query = "zzzznonexistent"
|
||||
h.updateFilter()
|
||||
|
||||
if b := h.selectedBinding(); b != nil {
|
||||
t.Fatal("Expected nil binding when no matches")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedBindingNilWhenNegativeIndex(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.updateFilter()
|
||||
h.selected_idx = -1
|
||||
|
||||
if b := h.selectedBinding(); b != nil {
|
||||
t.Fatal("Expected nil binding for negative index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedBindingNilWhenOverflowIndex(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
h.updateFilter()
|
||||
h.selected_idx = len(h.filtered_idx) + 10
|
||||
|
||||
if b := h.selectedBinding(); b != nil {
|
||||
t.Fatal("Expected nil binding for overflow index")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelpTextPreserved(t *testing.T) {
|
||||
h := newTestHandler()
|
||||
helpCount := 0
|
||||
for _, item := range h.all_items {
|
||||
if item.binding.Help != "" {
|
||||
helpCount++
|
||||
}
|
||||
}
|
||||
if helpCount == 0 {
|
||||
t.Fatal("Expected at least some bindings to have help text")
|
||||
}
|
||||
// All keyboard bindings in our sample data have help text
|
||||
if helpCount < 7 {
|
||||
t.Fatalf("Expected at least 7 bindings with help text, got %d", helpCount)
|
||||
}
|
||||
}
|
||||
|
||||
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.updateFilter()
|
||||
|
||||
if len(h.all_items) != 0 {
|
||||
t.Fatalf("Expected 0 items for empty data, got %d", len(h.all_items))
|
||||
}
|
||||
if len(h.filtered_idx) != 0 {
|
||||
t.Fatalf("Expected 0 filtered items, got %d", len(h.filtered_idx))
|
||||
}
|
||||
if b := h.selectedBinding(); b != nil {
|
||||
t.Fatal("Expected nil binding for empty data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallbackOrderingWithoutExplicitOrder(t *testing.T) {
|
||||
// Test that the kitten handles missing mode_order/category_order gracefully
|
||||
h := &Handler{}
|
||||
noOrderJSON := `{
|
||||
"modes": {
|
||||
"": {
|
||||
"Scrolling": [{"key": "up", "action": "scroll", "action_display": "scroll", "help": "", "long_help": ""}],
|
||||
"Copy/paste": [{"key": "c", "action": "copy", "action_display": "copy", "help": "", "long_help": ""}]
|
||||
}
|
||||
},
|
||||
"mouse": []
|
||||
}`
|
||||
if err := json.Unmarshal([]byte(noOrderJSON), &h.input_data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.flattenBindings()
|
||||
|
||||
if len(h.all_items) != 2 {
|
||||
t.Fatalf("Expected 2 items, got %d", len(h.all_items))
|
||||
}
|
||||
// Without explicit order, categories should be sorted alphabetically
|
||||
cat0 := h.all_items[0].binding.Category
|
||||
cat1 := h.all_items[1].binding.Category
|
||||
if cat0 > cat1 {
|
||||
t.Fatalf("Expected alphabetical category order, got %q then %q", cat0, cat1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user