mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-26 10:12:17 +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:
0
kittens/command_palette/__init__.py
Normal file
0
kittens/command_palette/__init__.py
Normal file
614
kittens/command_palette/main.go
Normal file
614
kittens/command_palette/main.go
Normal file
@@ -0,0 +1,614 @@
|
|||||||
|
// License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
package command_palette
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kovidgoyal/kitty/tools/cli"
|
||||||
|
"github.com/kovidgoyal/kitty/tools/fzf"
|
||||||
|
"github.com/kovidgoyal/kitty/tools/tui/loop"
|
||||||
|
"github.com/kovidgoyal/kitty/tools/utils"
|
||||||
|
"github.com/kovidgoyal/kitty/tools/wcswidth"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = fmt.Print
|
||||||
|
|
||||||
|
// JSON data structures matching Python collect_keys_data output
|
||||||
|
type Binding struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
ActionDisplay string `json:"action_display"`
|
||||||
|
Help string `json:"help"`
|
||||||
|
LongHelp string `json:"long_help"`
|
||||||
|
Category string
|
||||||
|
Mode string
|
||||||
|
IsMouse bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type InputData struct {
|
||||||
|
Modes map[string]map[string][]Binding `json:"modes"`
|
||||||
|
Mouse []Binding `json:"mouse"`
|
||||||
|
ModeOrder []string `json:"mode_order"`
|
||||||
|
CategoryOrder map[string][]string `json:"category_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisplayItem wraps a binding with its search text for FZF scoring
|
||||||
|
type DisplayItem struct {
|
||||||
|
binding Binding
|
||||||
|
searchText string // key + action_display + category for FZF
|
||||||
|
}
|
||||||
|
|
||||||
|
type displayLine struct {
|
||||||
|
text string
|
||||||
|
isHeader bool
|
||||||
|
isModeHdr bool
|
||||||
|
itemIdx int // index into filtered_idx, -1 for headers
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
query string
|
||||||
|
selected_idx int
|
||||||
|
scroll_offset int
|
||||||
|
input_data InputData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) initialize() (string, error) {
|
||||||
|
sz, err := h.lp.ScreenSize()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
h.screen_size = sz
|
||||||
|
h.lp.SetCursorVisible(true)
|
||||||
|
h.lp.SetCursorShape(loop.BAR_CURSOR, true)
|
||||||
|
h.lp.AllowLineWrapping(false)
|
||||||
|
h.lp.SetWindowTitle("Command Palette")
|
||||||
|
|
||||||
|
if err := h.loadData(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.matcher = fzf.NewFuzzyMatcher(fzf.DEFAULT_SCHEME)
|
||||||
|
h.updateFilter()
|
||||||
|
h.draw_screen()
|
||||||
|
h.lp.SendOverlayReady()
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) loadData() error {
|
||||||
|
data, err := io.ReadAll(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read stdin: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
return fmt.Errorf("no input data received on stdin; this kitten must be launched from kitty")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &h.input_data); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse input data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.flattenBindings()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenBindings converts the hierarchical mode/category/binding data into
|
||||||
|
// a flat list suitable for display and FZF scoring. Uses the explicit ordering
|
||||||
|
// arrays from Python since Go maps do not preserve insertion order.
|
||||||
|
func (h *Handler) flattenBindings() {
|
||||||
|
// Use explicit mode ordering from Python, falling back to sorted keys
|
||||||
|
modeNames := h.input_data.ModeOrder
|
||||||
|
if len(modeNames) == 0 {
|
||||||
|
modeNames = make([]string, 0, len(h.input_data.Modes))
|
||||||
|
for name := range h.input_data.Modes {
|
||||||
|
modeNames = append(modeNames, name)
|
||||||
|
}
|
||||||
|
sort.Slice(modeNames, func(i, j int) bool {
|
||||||
|
if modeNames[i] == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if modeNames[j] == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return modeNames[i] < modeNames[j]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, modeName := range modeNames {
|
||||||
|
categories, ok := h.input_data.Modes[modeName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use explicit category ordering from Python, falling back to sorted keys
|
||||||
|
catNames := h.input_data.CategoryOrder[modeName]
|
||||||
|
if len(catNames) == 0 {
|
||||||
|
catNames = make([]string, 0, len(categories))
|
||||||
|
for name := range categories {
|
||||||
|
catNames = append(catNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(catNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, catName := range catNames {
|
||||||
|
bindings, ok := categories[catName]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, b := range bindings {
|
||||||
|
b.Category = catName
|
||||||
|
b.Mode = modeName
|
||||||
|
b.IsMouse = false
|
||||||
|
searchText := b.Key + " " + b.ActionDisplay + " " + catName
|
||||||
|
if modeName != "" {
|
||||||
|
searchText += " " + modeName
|
||||||
|
}
|
||||||
|
h.all_items = append(h.all_items, DisplayItem{
|
||||||
|
binding: b,
|
||||||
|
searchText: searchText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouse bindings
|
||||||
|
for _, b := range h.input_data.Mouse {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
results, err := h.matcher.Score(h.search_texts, h.query)
|
||||||
|
if err != nil {
|
||||||
|
h.filtered_idx = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.selected_idx = 0
|
||||||
|
h.scroll_offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) selectedBinding() *Binding {
|
||||||
|
if h.selected_idx < 0 || h.selected_idx >= len(h.filtered_idx) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
idx := h.filtered_idx[h.selected_idx]
|
||||||
|
if idx < 0 || idx >= len(h.all_items) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &h.all_items[idx].binding
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) draw_screen() {
|
||||||
|
h.lp.StartAtomicUpdate()
|
||||||
|
defer h.lp.EndAtomicUpdate()
|
||||||
|
h.lp.ClearScreen()
|
||||||
|
|
||||||
|
width := int(h.screen_size.WidthCells)
|
||||||
|
height := int(h.screen_size.HeightCells)
|
||||||
|
if width < 10 || height < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout: line 1 = search bar, lines 2..height-2 = results,
|
||||||
|
// line height-1 = help text, line height = key hints
|
||||||
|
searchBarY := 1
|
||||||
|
resultsStartY := 2
|
||||||
|
helpY := height - 1
|
||||||
|
hintsY := height
|
||||||
|
resultsHeight := helpY - resultsStartY
|
||||||
|
if resultsHeight < 1 {
|
||||||
|
resultsHeight = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw search bar
|
||||||
|
h.lp.MoveCursorTo(1, searchBarY)
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-yellow", "> "))
|
||||||
|
h.lp.QueueWriteString(h.query)
|
||||||
|
|
||||||
|
// Draw results
|
||||||
|
if h.query == "" {
|
||||||
|
h.drawGroupedResults(resultsStartY, resultsHeight, width)
|
||||||
|
} else {
|
||||||
|
h.drawFlatResults(resultsStartY, resultsHeight, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw help text for selected binding
|
||||||
|
h.lp.MoveCursorTo(1, helpY)
|
||||||
|
if b := h.selectedBinding(); b != nil && b.Help != "" {
|
||||||
|
helpStr := b.Help
|
||||||
|
maxLen := width - 2
|
||||||
|
if maxLen < 3 {
|
||||||
|
maxLen = 3
|
||||||
|
}
|
||||||
|
if wcswidth.Stringwidth(helpStr) > maxLen {
|
||||||
|
// Truncate by runes to avoid breaking multi-byte characters
|
||||||
|
runes := []rune(helpStr)
|
||||||
|
for len(runes) > 0 && wcswidth.Stringwidth(string(runes))+3 > maxLen {
|
||||||
|
runes = runes[:len(runes)-1]
|
||||||
|
}
|
||||||
|
helpStr = string(runes) + "..."
|
||||||
|
}
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("dim italic", " "+helpStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw key hints footer
|
||||||
|
h.lp.MoveCursorTo(1, hintsY)
|
||||||
|
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"
|
||||||
|
matchInfo := ""
|
||||||
|
if h.query != "" {
|
||||||
|
matchInfo = fmt.Sprintf(" %d/%d", len(h.filtered_idx), len(h.all_items))
|
||||||
|
}
|
||||||
|
h.lp.QueueWriteString(" " + footer + h.lp.SprintStyled("dim", matchInfo))
|
||||||
|
|
||||||
|
// Position cursor at end of search text for typing
|
||||||
|
h.lp.MoveCursorTo(3+wcswidth.Stringwidth(h.query), searchBarY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) drawGroupedResults(startY, maxRows, width int) {
|
||||||
|
var lines []displayLine
|
||||||
|
lastMode := ""
|
||||||
|
lastCategory := ""
|
||||||
|
|
||||||
|
for fi, idx := range h.filtered_idx {
|
||||||
|
item := &h.all_items[idx]
|
||||||
|
b := &item.binding
|
||||||
|
|
||||||
|
// Mode header when mode changes
|
||||||
|
if b.Mode != lastMode {
|
||||||
|
lastMode = b.Mode
|
||||||
|
lastCategory = ""
|
||||||
|
if b.Mode != "" {
|
||||||
|
if len(lines) > 0 {
|
||||||
|
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
|
||||||
|
}
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: fmt.Sprintf(" Mode: %s", b.Mode),
|
||||||
|
isModeHdr: true, isHeader: true, itemIdx: -1,
|
||||||
|
})
|
||||||
|
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category header when category changes
|
||||||
|
if b.Category != lastCategory {
|
||||||
|
lastCategory = b.Category
|
||||||
|
if len(lines) > 0 && !lines[len(lines)-1].isHeader {
|
||||||
|
lines = append(lines, displayLine{itemIdx: -1, isHeader: true})
|
||||||
|
}
|
||||||
|
catWidth := wcswidth.Stringwidth(b.Category)
|
||||||
|
sepLen := max(0, width-catWidth-6)
|
||||||
|
sep := strings.Repeat("\u2500", sepLen)
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: fmt.Sprintf(" \u2500\u2500 %s %s", b.Category, sep),
|
||||||
|
isHeader: true, itemIdx: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binding line
|
||||||
|
keyWidth := wcswidth.Stringwidth(b.Key)
|
||||||
|
keyPad := 30
|
||||||
|
if keyWidth > keyPad-4 {
|
||||||
|
keyPad = keyWidth + 6
|
||||||
|
}
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: fmt.Sprintf(" %-*s %s", keyPad, b.Key, b.ActionDisplay),
|
||||||
|
itemIdx: fi,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.drawLines(lines, startY, maxRows, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) drawFlatResults(startY, maxRows, width int) {
|
||||||
|
var lines []displayLine
|
||||||
|
for fi, idx := range h.filtered_idx {
|
||||||
|
item := &h.all_items[idx]
|
||||||
|
b := &item.binding
|
||||||
|
keyWidth := wcswidth.Stringwidth(b.Key)
|
||||||
|
keyPad := 30
|
||||||
|
if keyWidth > keyPad-4 {
|
||||||
|
keyPad = keyWidth + 6
|
||||||
|
}
|
||||||
|
catSuffix := ""
|
||||||
|
if b.Mode != "" {
|
||||||
|
catSuffix = fmt.Sprintf(" [%s/%s]", b.Mode, b.Category)
|
||||||
|
} else {
|
||||||
|
catSuffix = fmt.Sprintf(" [%s]", b.Category)
|
||||||
|
}
|
||||||
|
lines = append(lines, displayLine{
|
||||||
|
text: fmt.Sprintf(" %-*s %-30s%s", keyPad, b.Key, b.ActionDisplay, catSuffix),
|
||||||
|
itemIdx: fi,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
h.drawLines(lines, startY, maxRows, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) drawLines(lines []displayLine, startY, maxRows, width int) {
|
||||||
|
if maxRows <= 0 || len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust scroll to keep selected item visible
|
||||||
|
selectedLineIdx := -1
|
||||||
|
for i, dl := range lines {
|
||||||
|
if dl.itemIdx == h.selected_idx {
|
||||||
|
selectedLineIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if selectedLineIdx >= 0 {
|
||||||
|
if selectedLineIdx < h.scroll_offset {
|
||||||
|
h.scroll_offset = selectedLineIdx
|
||||||
|
}
|
||||||
|
if selectedLineIdx >= h.scroll_offset+maxRows {
|
||||||
|
h.scroll_offset = selectedLineIdx - maxRows + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.scroll_offset = max(0, h.scroll_offset)
|
||||||
|
h.scroll_offset = min(h.scroll_offset, max(0, len(lines)-maxRows))
|
||||||
|
|
||||||
|
end := min(h.scroll_offset+maxRows, len(lines))
|
||||||
|
for row, li := range lines[h.scroll_offset:end] {
|
||||||
|
h.lp.MoveCursorTo(1, startY+row)
|
||||||
|
text := li.text
|
||||||
|
// Truncate at rune boundary to avoid breaking multi-byte characters
|
||||||
|
if wcswidth.Stringwidth(text) > width {
|
||||||
|
runes := []rune(text)
|
||||||
|
for len(runes) > 0 && wcswidth.Stringwidth(string(runes)) > width {
|
||||||
|
runes = runes[:len(runes)-1]
|
||||||
|
}
|
||||||
|
text = string(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if li.isModeHdr {
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("bold fg=magenta", text))
|
||||||
|
} else if li.isHeader {
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("fg=bright-blue", text))
|
||||||
|
} else if li.itemIdx == h.selected_idx {
|
||||||
|
// Selected item: highlight with reverse video
|
||||||
|
padded := text
|
||||||
|
textWidth := wcswidth.Stringwidth(text)
|
||||||
|
if textWidth < width {
|
||||||
|
padded += strings.Repeat(" ", width-textWidth)
|
||||||
|
}
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("fg=black bg=white", padded))
|
||||||
|
} else {
|
||||||
|
h.drawBindingLine(text, li.itemIdx, width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) drawBindingLine(text string, filteredIdx, width int) {
|
||||||
|
if filteredIdx < 0 || filteredIdx >= len(h.filtered_idx) {
|
||||||
|
h.lp.QueueWriteString(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := h.filtered_idx[filteredIdx]
|
||||||
|
if idx < 0 || idx >= len(h.all_items) {
|
||||||
|
h.lp.QueueWriteString(text)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := &h.all_items[idx].binding
|
||||||
|
|
||||||
|
// Style the key portion green, leave action unstyled
|
||||||
|
keyWidth := wcswidth.Stringwidth(b.Key)
|
||||||
|
keyPad := 30
|
||||||
|
if keyWidth > keyPad-4 {
|
||||||
|
keyPad = keyWidth + 6
|
||||||
|
}
|
||||||
|
keyPrefix := fmt.Sprintf(" %-*s", keyPad, b.Key)
|
||||||
|
rest := ""
|
||||||
|
if len(text) > len(keyPrefix) {
|
||||||
|
rest = text[len(keyPrefix):]
|
||||||
|
}
|
||||||
|
h.lp.QueueWriteString(h.lp.SprintStyled("fg=green", keyPrefix))
|
||||||
|
h.lp.QueueWriteString(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error {
|
||||||
|
if ev.MatchesPressOrRepeat("escape") {
|
||||||
|
ev.Handled = true
|
||||||
|
if h.query != "" {
|
||||||
|
h.query = ""
|
||||||
|
h.updateFilter()
|
||||||
|
h.draw_screen()
|
||||||
|
} else {
|
||||||
|
h.lp.Quit(0)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("enter") {
|
||||||
|
ev.Handled = true
|
||||||
|
h.triggerSelected()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("up") || ev.MatchesPressOrRepeat("ctrl+k") || ev.MatchesPressOrRepeat("ctrl+p") {
|
||||||
|
ev.Handled = true
|
||||||
|
h.moveSelection(-1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("down") || ev.MatchesPressOrRepeat("ctrl+j") || ev.MatchesPressOrRepeat("ctrl+n") {
|
||||||
|
ev.Handled = true
|
||||||
|
h.moveSelection(1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("page_up") {
|
||||||
|
ev.Handled = true
|
||||||
|
delta := max(1, int(h.screen_size.HeightCells)-4)
|
||||||
|
h.moveSelection(-delta)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("page_down") {
|
||||||
|
ev.Handled = true
|
||||||
|
delta := max(1, int(h.screen_size.HeightCells)-4)
|
||||||
|
h.moveSelection(delta)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("home") || ev.MatchesPressOrRepeat("ctrl+home") {
|
||||||
|
ev.Handled = true
|
||||||
|
h.selected_idx = 0
|
||||||
|
h.draw_screen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("end") || ev.MatchesPressOrRepeat("ctrl+end") {
|
||||||
|
ev.Handled = true
|
||||||
|
if len(h.filtered_idx) > 0 {
|
||||||
|
h.selected_idx = len(h.filtered_idx) - 1
|
||||||
|
}
|
||||||
|
h.draw_screen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.MatchesPressOrRepeat("backspace") {
|
||||||
|
ev.Handled = true
|
||||||
|
if h.query != "" {
|
||||||
|
g := wcswidth.SplitIntoGraphemes(h.query)
|
||||||
|
h.query = strings.Join(g[:len(g)-1], "")
|
||||||
|
h.updateFilter()
|
||||||
|
h.draw_screen()
|
||||||
|
} else {
|
||||||
|
h.lp.Beep()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) onText(text string, from_key_event bool, in_bracketed_paste bool) error {
|
||||||
|
h.query += text
|
||||||
|
h.updateFilter()
|
||||||
|
h.draw_screen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) onResize(old, new_size loop.ScreenSize) error {
|
||||||
|
h.screen_size = new_size
|
||||||
|
h.draw_screen()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) moveSelection(delta int) {
|
||||||
|
if len(h.filtered_idx) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.selected_idx += delta
|
||||||
|
h.selected_idx = max(0, h.selected_idx)
|
||||||
|
h.selected_idx = min(h.selected_idx, len(h.filtered_idx)-1)
|
||||||
|
h.draw_screen()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) triggerSelected() {
|
||||||
|
b := h.selectedBinding()
|
||||||
|
if b == nil || b.IsMouse {
|
||||||
|
h.lp.Beep()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send RC action command via DCS escape code.
|
||||||
|
// Do not set "self" or "match_window" — the action runs globally via
|
||||||
|
// the boss, same as if the user had pressed the keyboard shortcut.
|
||||||
|
payload := map[string]any{
|
||||||
|
"action": b.ActionDisplay,
|
||||||
|
}
|
||||||
|
|
||||||
|
rc := utils.RemoteControlCmd{
|
||||||
|
Cmd: "action",
|
||||||
|
Version: [3]int{0, 26, 0},
|
||||||
|
NoResponse: true,
|
||||||
|
}
|
||||||
|
rc.Payload = payload
|
||||||
|
|
||||||
|
data, err := json.Marshal(rc)
|
||||||
|
if err != nil {
|
||||||
|
h.lp.Beep()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.lp.QueueWriteString("\x1bP@kitty-cmd")
|
||||||
|
h.lp.QueueWriteString(string(data))
|
||||||
|
h.lp.QueueWriteString("\x1b\\")
|
||||||
|
h.lp.Quit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func main(cmd *cli.Command, opts *Options, args []string) (rc int, err error) {
|
||||||
|
lp, err := loop.New()
|
||||||
|
if err != nil {
|
||||||
|
return 1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := &Handler{lp: lp}
|
||||||
|
|
||||||
|
lp.OnInitialize = func() (string, error) {
|
||||||
|
return handler.initialize()
|
||||||
|
}
|
||||||
|
lp.OnFinalize = func() string { return "" }
|
||||||
|
lp.OnKeyEvent = handler.onKeyEvent
|
||||||
|
lp.OnText = handler.onText
|
||||||
|
lp.OnResize = handler.onResize
|
||||||
|
|
||||||
|
err = lp.Run()
|
||||||
|
if err != nil {
|
||||||
|
return 1, err
|
||||||
|
}
|
||||||
|
ds := lp.DeathSignalName()
|
||||||
|
if ds != "" {
|
||||||
|
fmt.Println("Killed by signal:", ds)
|
||||||
|
lp.KillIfSignalled()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rc = lp.ExitCode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func EntryPoint(parent *cli.Command) {
|
||||||
|
create_cmd(parent, main)
|
||||||
|
}
|
||||||
122
kittens/command_palette/main.py
Normal file
122
kittens/command_palette/main.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from kitty.typing_compat import BossType
|
||||||
|
|
||||||
|
from ..tui.handler import result_handler
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
from kitty.types import Shortcut
|
||||||
|
|
||||||
|
# Build action->group and action->help lookups
|
||||||
|
action_to_group: dict[str, str] = {}
|
||||||
|
action_to_help: dict[str, str] = {}
|
||||||
|
action_to_long_help: dict[str, str] = {}
|
||||||
|
for group_key, actions in get_all_actions().items():
|
||||||
|
for action in actions:
|
||||||
|
action_to_group[action.name] = groups[group_key]
|
||||||
|
action_to_help[action.name] = action.short_help
|
||||||
|
action_to_long_help[action.name] = action.long_help
|
||||||
|
|
||||||
|
modes: dict[str, dict[str, list[dict[str, str]]]] = {}
|
||||||
|
|
||||||
|
def as_sc(k: 'Any', v: KeyDefinition) -> Shortcut:
|
||||||
|
if v.is_sequence:
|
||||||
|
return Shortcut((v.trigger,) + v.rest)
|
||||||
|
return Shortcut((k,))
|
||||||
|
|
||||||
|
for mode_name, mode in opts.keyboard_modes.items():
|
||||||
|
categories: dict[str, list[dict[str, str]]] = {}
|
||||||
|
for key, defns in mode.keymap.items():
|
||||||
|
# Use last non-duplicate definition
|
||||||
|
seen: set[tuple[Any, ...]] = set()
|
||||||
|
uniq: list[KeyDefinition] = []
|
||||||
|
for d in reversed(defns):
|
||||||
|
uid = d.unique_identity_within_keymap
|
||||||
|
if uid not in seen:
|
||||||
|
seen.add(uid)
|
||||||
|
uniq.append(d)
|
||||||
|
for d in uniq:
|
||||||
|
sc = as_sc(key, d)
|
||||||
|
key_repr = sc.human_repr(opts.kitty_mod)
|
||||||
|
action_repr = d.human_repr()
|
||||||
|
# Determine category from first word of action definition
|
||||||
|
action_name = d.definition.split()[0] if d.definition else 'no_op'
|
||||||
|
category = action_to_group.get(action_name, 'Miscellaneous')
|
||||||
|
help_text = action_to_help.get(action_name, '')
|
||||||
|
long_help = action_to_long_help.get(action_name, '')
|
||||||
|
categories.setdefault(category, []).append({
|
||||||
|
'key': key_repr,
|
||||||
|
'action': action_name,
|
||||||
|
'action_display': action_repr,
|
||||||
|
'help': help_text,
|
||||||
|
'long_help': long_help,
|
||||||
|
})
|
||||||
|
# Sort within categories
|
||||||
|
for cat in categories:
|
||||||
|
categories[cat].sort(key=lambda b: b['key'])
|
||||||
|
# Order categories by the groups order
|
||||||
|
ordered: dict[str, list[dict[str, str]]] = {}
|
||||||
|
for group_title in groups.values():
|
||||||
|
if group_title in categories:
|
||||||
|
ordered[group_title] = categories.pop(group_title)
|
||||||
|
# Add any remaining
|
||||||
|
for cat_name, binds in sorted(categories.items()):
|
||||||
|
ordered[cat_name] = binds
|
||||||
|
modes[mode_name] = ordered
|
||||||
|
|
||||||
|
# Emit explicit mode and category ordering since JSON maps lose insertion order
|
||||||
|
mode_order = list(modes.keys())
|
||||||
|
category_order: dict[str, list[str]] = {}
|
||||||
|
for mode_name, cats in modes.items():
|
||||||
|
category_order[mode_name] = list(cats.keys())
|
||||||
|
|
||||||
|
# Mouse mappings
|
||||||
|
mouse: list[dict[str, str]] = []
|
||||||
|
for event, action in opts.mousemap.items():
|
||||||
|
key_repr = event.human_repr(opts.kitty_mod)
|
||||||
|
mouse.append({'key': key_repr, 'action': action, 'action_display': action, 'help': '', 'long_help': ''})
|
||||||
|
mouse.sort(key=lambda b: b['key'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'modes': modes,
|
||||||
|
'mouse': mouse,
|
||||||
|
'mode_order': mode_order,
|
||||||
|
'category_order': category_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(args: list[str]) -> None:
|
||||||
|
raise SystemExit('This must be run as kitten command-palette')
|
||||||
|
|
||||||
|
|
||||||
|
main.allow_remote_control = True # type: ignore[attr-defined]
|
||||||
|
main.remote_control_password = True # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
||||||
|
@result_handler(has_ready_notification=True)
|
||||||
|
def handle_result(args: list[str], data: dict[str, Any], target_window_id: int, boss: BossType) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
help_text = 'Browse and trigger keyboard shortcuts and actions'
|
||||||
|
usage = ''
|
||||||
|
OPTIONS = ''.format
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main(sys.argv)
|
||||||
|
elif __name__ == '__doc__':
|
||||||
|
cd = sys.cli_docs # type: ignore
|
||||||
|
cd['usage'] = usage
|
||||||
|
cd['options'] = OPTIONS
|
||||||
|
cd['help_text'] = help_text
|
||||||
|
cd['short_desc'] = help_text
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2294,6 +2294,12 @@ 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')
|
||||||
|
def command_palette(self) -> None:
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
data = collect_keys_data(get_options())
|
||||||
|
self.run_kitten_with_metadata('command-palette', input_data=json.dumps(data), window=self.window_for_dispatch)
|
||||||
|
|
||||||
@ac(
|
@ac(
|
||||||
'tab', '''
|
'tab', '''
|
||||||
Change the title of the active tab interactively, by typing in the new title.
|
Change the title of the active tab interactively, by typing in the new title.
|
||||||
|
|||||||
99
kitty_tests/command_palette.py
Normal file
99
kitty_tests/command_palette.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
|
from . import BaseTest
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandPalette(BaseTest):
|
||||||
|
|
||||||
|
def test_collect_keys_data(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
from kitty.actions import groups
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts)
|
||||||
|
self.assertIn('modes', data)
|
||||||
|
self.assertIn('mouse', data)
|
||||||
|
self.assertIn('', data['modes'], 'Default keyboard mode should be present')
|
||||||
|
default_mode = data['modes']['']
|
||||||
|
# Should have at least some categories
|
||||||
|
self.assertTrue(len(default_mode) > 0, 'Should have at least one category')
|
||||||
|
# All category names should be from the known groups
|
||||||
|
known_titles = set(groups.values())
|
||||||
|
for cat_name in default_mode:
|
||||||
|
self.assertIn(cat_name, known_titles, f'Unknown category: {cat_name}')
|
||||||
|
# Each category should have bindings with required fields
|
||||||
|
for cat_name, bindings in default_mode.items():
|
||||||
|
self.assertIsInstance(bindings, list)
|
||||||
|
for b in bindings:
|
||||||
|
self.assertIn('key', b)
|
||||||
|
self.assertIn('action', b)
|
||||||
|
self.assertIn('action_display', b)
|
||||||
|
self.assertIn('help', b)
|
||||||
|
self.assertIn('long_help', b)
|
||||||
|
self.assertIsInstance(b['key'], str)
|
||||||
|
self.assertIsInstance(b['action'], str)
|
||||||
|
self.assertTrue(len(b['key']) > 0)
|
||||||
|
self.assertTrue(len(b['action']) > 0)
|
||||||
|
# Mouse mappings
|
||||||
|
self.assertIsInstance(data['mouse'], list)
|
||||||
|
for b in data['mouse']:
|
||||||
|
self.assertIn('key', b)
|
||||||
|
self.assertIn('action', b)
|
||||||
|
self.assertIn('action_display', b)
|
||||||
|
|
||||||
|
def test_collect_keys_categories_ordered(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
from kitty.actions import groups
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts)
|
||||||
|
default_mode = data['modes']['']
|
||||||
|
cat_names = list(default_mode.keys())
|
||||||
|
group_titles = list(groups.values())
|
||||||
|
# Categories should appear in the same order as defined in groups
|
||||||
|
indices = []
|
||||||
|
for cat in cat_names:
|
||||||
|
if cat in group_titles:
|
||||||
|
indices.append(group_titles.index(cat))
|
||||||
|
self.ae(indices, sorted(indices), 'Categories should be ordered according to groups dict')
|
||||||
|
|
||||||
|
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)
|
||||||
|
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')
|
||||||
|
|
||||||
|
def test_collect_keys_has_help_text(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts)
|
||||||
|
# At least some bindings should have help text
|
||||||
|
has_help = False
|
||||||
|
for cat_name, bindings in data['modes'][''].items():
|
||||||
|
for b in bindings:
|
||||||
|
if b['help']:
|
||||||
|
has_help = True
|
||||||
|
break
|
||||||
|
if has_help:
|
||||||
|
break
|
||||||
|
self.assertTrue(has_help, 'At least some bindings should have help text')
|
||||||
|
|
||||||
|
def test_ordering_arrays_present(self):
|
||||||
|
from kittens.command_palette.main import collect_keys_data
|
||||||
|
opts = self.set_options()
|
||||||
|
data = collect_keys_data(opts)
|
||||||
|
# mode_order should list all modes
|
||||||
|
self.assertIn('mode_order', data)
|
||||||
|
self.assertIsInstance(data['mode_order'], list)
|
||||||
|
self.ae(set(data['mode_order']), set(data['modes'].keys()))
|
||||||
|
# category_order should list categories for each mode
|
||||||
|
self.assertIn('category_order', data)
|
||||||
|
self.assertIsInstance(data['category_order'], dict)
|
||||||
|
for mode_name in data['modes']:
|
||||||
|
self.assertIn(mode_name, data['category_order'])
|
||||||
|
self.ae(
|
||||||
|
set(data['category_order'][mode_name]),
|
||||||
|
set(data['modes'][mode_name].keys()),
|
||||||
|
f'category_order for mode {mode_name!r} should match modes keys',
|
||||||
|
)
|
||||||
@@ -24,7 +24,7 @@ exec_kitty() {
|
|||||||
|
|
||||||
|
|
||||||
is_wrapped_kitten() {
|
is_wrapped_kitten() {
|
||||||
wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files"
|
wrapped_kittens="clipboard icat hyperlinked_grep ask hints unicode_input ssh themes diff show_key transfer query_terminal choose-files command-palette"
|
||||||
[ -n "$1" ] && {
|
[ -n "$1" ] && {
|
||||||
case " $wrapped_kittens " in
|
case " $wrapped_kittens " in
|
||||||
*" $1 "*) printf "%s" "$1" ;;
|
*" $1 "*) printf "%s" "$1" ;;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kovidgoyal/kitty/kittens/ask"
|
"github.com/kovidgoyal/kitty/kittens/ask"
|
||||||
"github.com/kovidgoyal/kitty/kittens/choose_files"
|
"github.com/kovidgoyal/kitty/kittens/choose_files"
|
||||||
|
"github.com/kovidgoyal/kitty/kittens/command_palette"
|
||||||
"github.com/kovidgoyal/kitty/kittens/choose_fonts"
|
"github.com/kovidgoyal/kitty/kittens/choose_fonts"
|
||||||
"github.com/kovidgoyal/kitty/kittens/clipboard"
|
"github.com/kovidgoyal/kitty/kittens/clipboard"
|
||||||
"github.com/kovidgoyal/kitty/kittens/desktop_ui"
|
"github.com/kovidgoyal/kitty/kittens/desktop_ui"
|
||||||
@@ -97,6 +98,8 @@ func KittyToolEntryPoints(root *cli.Command) {
|
|||||||
choose_fonts.EntryPoint(root)
|
choose_fonts.EntryPoint(root)
|
||||||
// choose-files
|
// choose-files
|
||||||
choose_files.EntryPoint(root)
|
choose_files.EntryPoint(root)
|
||||||
|
// command-palette
|
||||||
|
command_palette.EntryPoint(root)
|
||||||
// query-terminal
|
// query-terminal
|
||||||
query_terminal.EntryPoint(root)
|
query_terminal.EntryPoint(root)
|
||||||
// __pytest__
|
// __pytest__
|
||||||
|
|||||||
Reference in New Issue
Block a user