diff --git a/shell-integration/zsh/completions/_kitty b/shell-integration/zsh/completions/_kitty index 694131ee6..0ed00f156 100644 --- a/shell-integration/zsh/completions/_kitty +++ b/shell-integration/zsh/completions/_kitty @@ -3,5 +3,5 @@ (( ${+commands[kitty]} )) || builtin return builtin local src cmd=${(F)words:0:$CURRENT} # Send all words up to the word the cursor is currently on. -src=$(builtin command kitty +complete zsh "_matcher=$_matcher" <<<$cmd) || builtin return +src=$(builtin command kitty-tool __complete__ zsh "_matcher=$_matcher" <<<$cmd) || builtin return builtin eval "$src" diff --git a/tools/completion/files.go b/tools/completion/files.go index 2b3bb3809..6512179cf 100644 --- a/tools/completion/files.go +++ b/tools/completion/files.go @@ -269,6 +269,7 @@ func directory_completer(title string, relative_to relative_to) completion_func return func(completions *Completions, word string, arg_num int) { mg := completions.add_match_group(title) + mg.NoTrailingSpace = true mg.IsFiles = true complete_files(word, func(entry *FileEntry) { if entry.mode.IsDir() { diff --git a/tools/completion/kitty.go b/tools/completion/kitty.go index a71a330ab..90dd79b10 100644 --- a/tools/completion/kitty.go +++ b/tools/completion/kitty.go @@ -55,6 +55,7 @@ func complete_kitty(completions *Completions, word string, arg_num int) { func complete_kitty_override(title string, names []string) completion_func { return func(completions *Completions, word string, arg_num int) { mg := completions.add_match_group(title) + mg.NoTrailingSpace = true for _, q := range names { if strings.HasPrefix(q, word) { mg.add_match(q + "=") @@ -66,6 +67,7 @@ func complete_kitty_override(title string, names []string) completion_func { func complete_kitty_listen_on(completions *Completions, word string, arg_num int) { if !strings.Contains(word, ":") { mg := completions.add_match_group("Address family") + mg.NoTrailingSpace = true for _, q := range []string{"unix:", "tcp:"} { if strings.HasPrefix(q, word) { mg.add_match(q) diff --git a/tools/completion/main.go b/tools/completion/main.go index 302072920..0908d701f 100644 --- a/tools/completion/main.go +++ b/tools/completion/main.go @@ -43,7 +43,11 @@ func main(args []string) error { output_type = args[0] args = args[1:] } - shell_state := make(map[string]string, len(args)) + n := len(args) + if n < 1 { + n = 1 + } + shell_state := make(map[string]string, n) for _, arg := range args { k, v, found := utils.Cut(arg, "=") if !found { diff --git a/tools/completion/parse-args.go b/tools/completion/parse-args.go index 9e142770e..2c996b750 100644 --- a/tools/completion/parse-args.go +++ b/tools/completion/parse-args.go @@ -55,12 +55,17 @@ func (self *Completions) add_options_group(options []*Option, word string) { if word == "-" { group.Matches = append(group.Matches, &Match{Word: "--", Description: "End of options"}) for _, opt := range options { + has_single_letter_alias := false for _, q := range opt.Aliases { if len(q) == 1 { group.add_match("-"+q, opt.Description) + has_single_letter_alias = true break } } + if !has_single_letter_alias { + group.add_match("--"+opt.Aliases[0], opt.Description) + } } } else { runes := []rune(word) diff --git a/tools/completion/types.go b/tools/completion/types.go index 141471687..d99c35523 100644 --- a/tools/completion/types.go +++ b/tools/completion/types.go @@ -2,7 +2,11 @@ package completion -import "strings" +import ( + "kitty/tools/utils" + "kitty/tools/wcswidth" + "strings" +) type Match struct { Word string `json:"word,omitempty"` @@ -28,6 +32,46 @@ func (self *MatchGroup) add_prefix_to_all_matches(prefix string) { } } +func (self *MatchGroup) remove_prefix_from_all_matches(prefix string) { + for _, m := range self.Matches { + m.Word = m.Word[len(prefix):] + } +} + +func (self *MatchGroup) has_descriptions() bool { + for _, m := range self.Matches { + if m.Description != "" { + return true + } + } + return false +} + +func (self *MatchGroup) max_visual_word_length(limit int) int { + ans := 0 + for _, m := range self.Matches { + if q := wcswidth.Stringwidth(m.Word); q > ans { + ans = q + if ans > limit { + return limit + } + } + } + return ans +} + +func (self *MatchGroup) longest_common_prefix() string { + limit := len(self.Matches) + i := 0 + return utils.LongestCommon(func() (string, bool) { + if i < limit { + i++ + return self.Matches[i-1].Word, false + } + return "", true + }, true) +} + type Completions struct { Groups []*MatchGroup `json:"groups,omitempty"` diff --git a/tools/completion/zsh.go b/tools/completion/zsh.go index 8537030c8..c3d093ba9 100644 --- a/tools/completion/zsh.go +++ b/tools/completion/zsh.go @@ -5,6 +5,11 @@ package completion import ( "bufio" "fmt" + "kitty/tools/cli/markup" + "kitty/tools/tty" + "kitty/tools/utils" + "kitty/tools/wcswidth" + "path/filepath" "strings" ) @@ -12,8 +17,11 @@ var _ = fmt.Print func zsh_input_parser(data []byte, shell_state map[string]string) ([][]string, error) { matcher := shell_state["_matcher"] - q := strings.Split(strings.ToLower(matcher), ":")[0][:1] - if strings.Contains("lrbe", q) { + q := "" + if matcher != "" { + q = strings.Split(strings.ToLower(matcher), ":")[0][:1] + } + if q != "" && strings.Contains("lrbe", q) { // this is zsh anchor based matching // https://zsh.sourceforge.io/Doc/Release/Completion-Widgets.html#Completion-Matching-Control // can be specified with matcher-list and some systems do it by default, @@ -38,8 +46,98 @@ func zsh_input_parser(data []byte, shell_state map[string]string) ([][]string, e return [][]string{words}, nil } +func fmt_desc(word, desc string, max_word_len int, f *markup.Context, screen_width int) string { + if desc == "" { + return word + } + line, _, _ := utils.Cut(strings.TrimSpace(desc), "\n") + desc = f.Prettify(line) + + multiline := false + max_desc_len := screen_width - 2 + word_len := wcswidth.Stringwidth(word) + if word_len > max_word_len { + multiline = true + } else { + word += strings.Repeat(" ", max_word_len-word_len) + max_desc_len = screen_width - max_word_len - 3 + } + if wcswidth.Stringwidth(desc) > max_desc_len { + desc = wcswidth.TruncateToVisualLength(desc, max_desc_len-2) + "…" + } + + if multiline { + return word + "\n " + desc + } + return word + " " + desc +} + +func serialize(completions *Completions, f *markup.Context, screen_width int) ([]byte, error) { + cmd := strings.Builder{} + output := strings.Builder{} + for _, mg := range completions.Groups { + cmd.WriteString("compadd -U -J ") + cmd.WriteString(utils.QuoteStringForSH(mg.Title)) + cmd.WriteString(" -X ") + cmd.WriteString(utils.QuoteStringForSH("%B" + mg.Title + "%b")) + if mg.NoTrailingSpace { + cmd.WriteString(" -S ''") + } + if mg.IsFiles { + cmd.WriteString(" -f") + if len(mg.Matches) > 1 { + lcp := mg.longest_common_prefix() + if strings.Contains(lcp, utils.Sep) { + lcp = strings.TrimRight(filepath.Dir(lcp), utils.Sep) + utils.Sep + cmd.WriteString(" -p ") + cmd.WriteString(utils.QuoteStringForSH(lcp)) + mg.remove_prefix_from_all_matches(lcp) + } + } + } else if len(mg.Matches) > 1 && strings.HasPrefix(mg.Matches[0].Word, "--") && strings.Contains(mg.Matches[0].Word, "=") { + lcp, _, _ := utils.Cut(mg.longest_common_prefix(), "=") + lcp += "=" + if len(lcp) > 3 { + cmd.WriteString(" -p ") + cmd.WriteString(utils.QuoteStringForSH(lcp)) + mg.remove_prefix_from_all_matches(lcp) + } + } + if mg.has_descriptions() { + fmt.Fprintln(&output, "compdescriptions=(") + limit := mg.max_visual_word_length(16) + for _, m := range mg.Matches { + fmt.Fprintln(&output, utils.QuoteStringForSH(fmt_desc(m.Word, m.Description, limit, f, screen_width))) + } + fmt.Fprintln(&output, ")") + cmd.WriteString(" -l -d compdescriptions") + } + cmd.WriteString(" --") + for _, m := range mg.Matches { + cmd.WriteString(" ") + cmd.WriteString(utils.QuoteStringForSH(m.Word)) + } + fmt.Fprintln(&output, cmd.String(), ";") + } + return []byte(output.String()), nil +} + func zsh_output_serializer(completions []*Completions, shell_state map[string]string) ([]byte, error) { - return nil, nil + var f *markup.Context + screen_width := 80 + ctty, err := tty.OpenControllingTerm() + if err == nil { + sz, err := ctty.GetSize() + ctty.Close() + if err == nil { + screen_width = int(sz.Col) + f = markup.New(true) + } + } + if f == nil { + f = markup.New(false) + } + return serialize(completions[0], f, screen_width) } func init() {