diff --git a/tools/cmd/at/shell.go b/tools/cmd/at/shell.go index dc7f8365d..64e83a622 100644 --- a/tools/cmd/at/shell.go +++ b/tools/cmd/at/shell.go @@ -61,6 +61,7 @@ func shell_loop(rl *readline.Readline, kill_if_signaled bool) (int, error) { rl.Redraw() return nil } + lp.ClearToEndOfScreen() return ErrExec } return err @@ -176,7 +177,7 @@ func exec_command(rl *readline.Readline, cmdline string) bool { } func completions(before_cursor, after_cursor string) (ans *cli.Completions) { - const prefix = "kitty @ " + const prefix = "kitty-tool @ " text := prefix + before_cursor argv, position_of_last_arg := shlex.SplitForCompletion(text) if len(argv) == 0 || position_of_last_arg < len(prefix) { diff --git a/tools/tui/readline/actions_test.go b/tools/tui/readline/actions_test.go index 378ff3fc4..a72bb9997 100644 --- a/tools/tui/readline/actions_test.go +++ b/tools/tui/readline/actions_test.go @@ -17,10 +17,17 @@ import ( var _ = fmt.Print +func new_rl() *Readline { + lp, _ := loop.New() + rl := New(lp, RlInit{Prompt: "$$ "}) + rl.screen_width = 10 + rl.screen_height = 100 + return rl +} + func test_func(t *testing.T) func(string, func(*Readline), ...string) *Readline { return func(initial string, prepare func(rl *Readline), expected ...string) *Readline { - lp, _ := loop.New() - rl := New(lp, RlInit{}) + rl := new_rl() rl.add_text(initial) if prepare != nil { prepare(rl) @@ -64,9 +71,7 @@ func TestAddText(t *testing.T) { } func TestGetScreenLines(t *testing.T) { - lp, _ := loop.New() - rl := New(lp, RlInit{Prompt: "$$ "}) - rl.screen_width = 10 + rl := new_rl() p := func(primary bool) Prompt { if primary { @@ -189,9 +194,7 @@ func TestCursorMovement(t *testing.T) { right(rl, 1, 1, false) }, "à", "b") - lp, _ := loop.New() - rl := New(lp, RlInit{Prompt: "$$ "}) - rl.screen_width = 10 + rl := new_rl() vert := func(amt int, moved_amt int, text_upto_cursor_pos string, initials ...Position) { initial := Position{} @@ -266,8 +269,7 @@ func TestCursorMovement(t *testing.T) { } func TestYanking(t *testing.T) { - lp, _ := loop.New() - rl := New(lp, RlInit{Prompt: "$$ "}) + rl := new_rl() as_slice := func(l *list.List) []string { ans := make([]string, 0, l.Len()) @@ -386,8 +388,9 @@ func TestEraseChars(t *testing.T) { } func TestNumberArgument(t *testing.T) { - lp, _ := loop.New() - rl := New(lp, RlInit{Prompt: "$$ "}) + rl := new_rl() + rl.screen_width = 100 + test := func(ac Action, before_cursor, after_cursor string) { rl.dispatch_key_action(ac) if diff := cmp.Diff(before_cursor, rl.text_upto_cursor_pos()); diff != "" { @@ -431,8 +434,7 @@ func TestNumberArgument(t *testing.T) { } func TestHistory(t *testing.T) { - lp, _ := loop.New() - rl := New(lp, RlInit{Prompt: "$$ "}) + rl := new_rl() add_item := func(x string) { rl.history.AddItem(x, 0) @@ -516,8 +518,6 @@ func TestHistory(t *testing.T) { } func TestReadlineCompletion(t *testing.T) { - lp, _ := loop.New() - completer := func(before_cursor, after_cursor string) (ans *cli.Completions) { root := cli.NewRootCommand() c := root.AddSubCommand(&cli.Command{Name: "test-completion"}) @@ -535,7 +535,8 @@ func TestReadlineCompletion(t *testing.T) { return } - rl := New(lp, RlInit{Prompt: "$$ ", Completer: completer}) + rl := new_rl() + rl.completions.completer = completer ah := func(before_cursor, after_cursor string) { ab := rl.text_upto_cursor_pos() diff --git a/tools/tui/readline/api.go b/tools/tui/readline/api.go index 90ee36922..e07ab7423 100644 --- a/tools/tui/readline/api.go +++ b/tools/tui/readline/api.go @@ -117,9 +117,9 @@ type Readline struct { input_state InputState // The number of lines after the initial line on the screen - cursor_y int - screen_width int - last_yank_extent struct { + cursor_y int + screen_width, screen_height int + last_yank_extent struct { start, end Position } bracketed_paste_buffer strings.Builder @@ -259,10 +259,7 @@ func (self *Readline) CursorAtEndOfLine() bool { } func (self *Readline) OnResize(old_size loop.ScreenSize, new_size loop.ScreenSize) error { - self.screen_width = int(new_size.CellWidth) - if self.screen_width < 1 { - self.screen_width = 1 - } + self.screen_width, self.screen_height = 0, 0 self.Redraw() return nil } diff --git a/tools/tui/readline/completion.go b/tools/tui/readline/completion.go index 477b784b0..00ea9cd7b 100644 --- a/tools/tui/readline/completion.go +++ b/tools/tui/readline/completion.go @@ -4,9 +4,11 @@ package readline import ( "fmt" + "strings" "kitty/tools/cli" "kitty/tools/utils" + "kitty/tools/wcswidth" ) var _ = fmt.Print @@ -16,6 +18,9 @@ type completion struct { results *cli.Completions results_displayed, forwards bool num_of_matches, current_match int + rendered_at_screen_width int + rendered_lines []string + last_rendered_above bool } func (self *completion) initialize() { @@ -64,23 +69,29 @@ func (self *Readline) complete(forwards bool, repeat_count uint) bool { return false } if self.last_action == ActionCompleteForward || self.last_action == ActionCompleteBackward { + if c.current.num_of_matches == 0 { + return false + } delta := -1 if forwards { delta = 1 } + repeat_count %= uint(c.current.num_of_matches) delta *= int(repeat_count) c.current.current_match = (c.current.current_match + delta + c.current.num_of_matches) % c.current.num_of_matches repeat_count = 0 } else { before, after := self.text_upto_cursor_pos(), self.text_after_cursor_pos() - if before == "" { - return false - } c.current = completion{before_cursor: before, after_cursor: after, forwards: forwards, results: c.completer(before, after)} c.current.initialize() if repeat_count > 0 { repeat_count-- } + if c.current.current_match != 0 { + if self.loop != nil { + self.loop.Beep() + } + } } c.current.forwards = forwards if c.current.results == nil { @@ -107,3 +118,158 @@ func (self *Readline) complete(forwards bool, repeat_count uint) bool { } return true } + +func (self *Readline) screen_lines_for_match_group_with_descriptions(g *cli.MatchGroup, lines []string) []string { + maxw := 0 + lengths := make(map[string]int) + for _, m := range g.Matches { + l := wcswidth.Stringwidth(m.Word) + lengths[m.Word] = l + if l > maxw { + maxw = l + } + } + for _, m := range g.Matches { + p := m.Word + strings.Repeat(" ", maxw-lengths[m.Word]) + line, _, _ := utils.Cut(strings.TrimSpace(m.Description), "\n") + line = p + " - " + self.fmt_ctx.Prettify(line) + truncated := wcswidth.TruncateToVisualLength(line, self.screen_width-1) + "\x1b[m" + if len(truncated) < len(line) { + line = truncated + "…" + } + lines = append(lines, line) + } + return lines +} + +type cell struct { + text string + length int +} + +func (self cell) whitespace(desired_length int) string { + return strings.Repeat(" ", desired_length-self.length) +} + +type column struct { + cells []cell + length int + is_last bool +} + +func (self *column) update_length() int { + self.length = 0 + for _, c := range self.cells { + if c.length > self.length { + self.length = c.length + } + } + if !self.is_last { + self.length++ + } + return self.length +} + +func layout_words_in_table(words []string, lengths map[string]int, num_cols int) ([]column, int) { + cols := make([]column, num_cols) + for i, col := range cols { + col.cells = make([]cell, 0, len(words)) + if i == len(cols)-1 { + col.is_last = true + } + } + r, c := 0, 0 + for _, word := range words { + cols[r].cells = append(cols[r].cells, cell{word, lengths[word]}) + c++ + if c > num_cols { + c = 0 + r++ + } + } + total_length := 0 + for i, col := range cols { + total_length += col.update_length() + for i > 0 && len(col.cells) < len(cols[i-1].cells) { + col.cells = append(col.cells, cell{}) + } + } + return cols, total_length +} + +func (self *Readline) screen_lines_for_match_group_without_descriptions(g *cli.MatchGroup, lines []string) []string { + words := make([]string, len(g.Matches)) + lengths := make(map[string]int, len(words)) + max_length := 0 + for i, m := range g.Matches { + words[i] = m.Word + l := wcswidth.Stringwidth(words[i]) + lengths[words[i]] = l + if l > max_length { + max_length = l + } + } + var ans []column + ncols := utils.Max(1, self.screen_width/(max_length+1)) + for { + cols, total_length := layout_words_in_table(words, lengths, ncols) + if total_length > self.screen_width { + break + } + ans = cols + ncols++ + } + if ans == nil { + for _, w := range words { + if lengths[w] > self.screen_width { + lines = append(lines, wcswidth.TruncateToVisualLength(w, self.screen_width)) + } else { + lines = append(lines, w) + } + } + } else { + for r := 0; r < len(ans[0].cells); r++ { + w := strings.Builder{} + w.Grow(self.screen_width) + for c := 0; c < len(ans); c++ { + cell := ans[c].cells[r] + w.WriteString(cell.text) + if !ans[c].is_last { + w.WriteString(cell.whitespace(ans[r].length)) + } + } + lines = append(lines, w.String()) + } + } + return lines +} + +func (self *Readline) completion_screen_lines() ([]string, bool) { + if self.completions.current.results == nil || self.completions.current.num_of_matches < 2 { + return []string{}, false + } + if len(self.completions.current.rendered_lines) > 0 && self.completions.current.rendered_at_screen_width == self.screen_width { + return self.completions.current.rendered_lines, true + } + lines := make([]string, 0, self.completions.current.num_of_matches) + for _, g := range self.completions.current.results.Groups { + if g.Title != "" { + lines = append(lines, self.fmt_ctx.Title(g.Title)) + } + has_descriptions := false + for _, m := range g.Matches { + if m.Description != "" { + has_descriptions = true + break + } + } + if has_descriptions { + lines = self.screen_lines_for_match_group_with_descriptions(g, lines) + } else { + lines = self.screen_lines_for_match_group_without_descriptions(g, lines) + } + } + self.completions.current.rendered_lines = lines + self.completions.current.rendered_at_screen_width = self.screen_width + return lines, false +} diff --git a/tools/tui/readline/draw.go b/tools/tui/readline/draw.go index e1850a7dc..e4e5edcc7 100644 --- a/tools/tui/readline/draw.go +++ b/tools/tui/readline/draw.go @@ -4,6 +4,7 @@ package readline import ( "fmt" + "kitty/tools/tui/loop" "kitty/tools/utils" "kitty/tools/wcswidth" "strings" @@ -12,18 +13,20 @@ import ( var _ = fmt.Print func (self *Readline) update_current_screen_size() { - screen_size, err := self.loop.ScreenSize() - if err != nil { + var screen_size loop.ScreenSize + var err error + if self.loop != nil { + screen_size, err = self.loop.ScreenSize() + if err != nil { + screen_size.WidthCells = 80 + screen_size.HeightCells = 24 + } + } else { screen_size.WidthCells = 80 screen_size.HeightCells = 24 } - if screen_size.WidthCells < 1 { - screen_size.WidthCells = 1 - } - if screen_size.HeightCells < 1 { - screen_size.HeightCells = 1 - } - self.screen_width = int(screen_size.WidthCells) + self.screen_width = utils.Max(1, int(screen_size.WidthCells)) + self.screen_height = utils.Max(1, int(screen_size.HeightCells)) } type ScreenLine struct { @@ -82,7 +85,7 @@ func (self *Readline) apply_syntax_highlighting() (lines []string, cursor Positi } func (self *Readline) get_screen_lines() []*ScreenLine { - if self.screen_width == 0 { + if self.screen_width == 0 || self.screen_height == 0 { self.update_current_screen_size() } lines, cursor := self.apply_syntax_highlighting() @@ -129,7 +132,7 @@ func (self *Readline) get_screen_lines() []*ScreenLine { } func (self *Readline) redraw() { - if self.screen_width == 0 { + if self.screen_width == 0 || self.screen_height == 0 { self.update_current_screen_size() } if self.screen_width < 4 { @@ -140,11 +143,38 @@ func (self *Readline) redraw() { } self.loop.QueueWriteString("\r") self.loop.ClearToEndOfScreen() + prompt_lines := self.get_screen_lines() + csl, csl_cached := self.completion_screen_lines() + render_completion_above := len(csl)+len(prompt_lines) > self.screen_height + completion_needs_render := len(csl) > 0 && (!render_completion_above || !self.completions.current.last_rendered_above || !csl_cached) cursor_x := -1 cursor_y := 0 move_cursor_up_by := 0 + + render_completion_lines := func() int { + if completion_needs_render { + if render_completion_above { + self.loop.QueueWriteString("\r") + } else { + self.loop.QueueWriteString("\r\n") + } + for i, cl := range csl { + self.loop.QueueWriteString(cl) + if i < len(csl)-1 || render_completion_above { + self.loop.QueueWriteString("\n\r") + } + + } + return len(csl) + } + return 0 + } + self.loop.AllowLineWrapping(false) - for i, sl := range self.get_screen_lines() { + if render_completion_above { + render_completion_lines() + } + for i, sl := range prompt_lines { self.loop.QueueWriteString("\r") if i > 0 { self.loop.QueueWriteString("\n") @@ -160,6 +190,9 @@ func (self *Readline) redraw() { } cursor_y++ } + if !render_completion_above { + move_cursor_up_by += render_completion_lines() + } self.loop.AllowLineWrapping(true) self.loop.MoveCursorVertically(-move_cursor_up_by) self.loop.QueueWriteString("\r") diff --git a/tools/tui/readline/keys.go b/tools/tui/readline/keys.go index e9db60d7a..272b89b87 100644 --- a/tools/tui/readline/keys.go +++ b/tools/tui/readline/keys.go @@ -88,6 +88,8 @@ func default_shortcuts() *ShortcutMap { sm.AddOrPanic(ActionNumericArgumentDigit9, "alt+9") sm.AddOrPanic(ActionNumericArgumentDigitMinus, "alt+-") + sm.AddOrPanic(ActionCompleteForward, "Tab") + sm.AddOrPanic(ActionCompleteBackward, "Shift+Tab") _default_shortcuts = sm } return _default_shortcuts