mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-06 01:05:48 +02:00
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)
310 lines
8.8 KiB
Go
310 lines
8.8 KiB
Go
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)
|
|
}
|
|
}
|