From 0aa55fb755e56dff3de049ff7e7614856ce2e7af Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Tue, 7 Mar 2023 08:50:12 +0530 Subject: [PATCH] Start work on porting the ask kitten --- kittens/ask/main.py | 8 + shell-integration/ssh/kitty | 2 +- tools/cmd/ask/choices.go | 434 ++++++++++++++++++++++++++++++++++++ tools/cmd/ask/main.go | 39 ++++ tools/cmd/tool/main.go | 3 + 5 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 tools/cmd/ask/choices.go create mode 100644 tools/cmd/ask/main.go diff --git a/kittens/ask/main.py b/kittens/ask/main.py index 28451cb86..db819313c 100644 --- a/kittens/ask/main.py +++ b/kittens/ask/main.py @@ -539,3 +539,11 @@ if __name__ == '__main__': if ans: import json print(json.dumps(ans)) +elif __name__ == '__doc__': + import sys + + cd = sys.cli_docs # type: ignore + cd['usage'] = '' + cd['options'] = option_text + cd['help_text'] = 'Ask the user for input' + cd['short_desc'] = 'Ask the user for input' diff --git a/shell-integration/ssh/kitty b/shell-integration/ssh/kitty index ab9d505f4..21c1f6dea 100755 --- a/shell-integration/ssh/kitty +++ b/shell-integration/ssh/kitty @@ -24,7 +24,7 @@ exec_kitty() { is_wrapped_kitten() { - wrapped_kittens="clipboard icat hyperlinked_grep unicode_input ssh" + wrapped_kittens="clipboard icat hyperlinked_grep ask unicode_input ssh" [ -n "$1" ] && { case " $wrapped_kittens " in *" $1 "*) printf "%s" "$1" ;; diff --git a/tools/cmd/ask/choices.go b/tools/cmd/ask/choices.go new file mode 100644 index 000000000..e62a96d80 --- /dev/null +++ b/tools/cmd/ask/choices.go @@ -0,0 +1,434 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package ask + +import ( + "bufio" + "fmt" + "io" + "kitty/tools/cli/markup" + "kitty/tools/tui/loop" + "kitty/tools/utils" + "kitty/tools/utils/style" + "kitty/tools/wcswidth" + "os" + "regexp" + "strings" + "unicode" +) + +var _ = fmt.Print + +type Choice struct { + text string + idx int + color, letter string +} + +func (self Choice) prefix() string { + return string([]rune(self.text)[:self.idx]) +} + +func (self Choice) display_letter() string { + return string([]rune(self.text)[self.idx]) +} + +func (self Choice) suffix() string { + return string([]rune(self.text)[self.idx+1:]) +} + +type Range struct { + start, end, y int +} + +func (self *Range) has_point(x, y int) bool { + return y == self.y && self.start <= x && x <= self.end +} + +func truncate_at_space(text string, width int) (string, string) { + truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width) + if p >= len(text) { + return text, "" + } + i := strings.LastIndexByte(truncated, ' ') + if i > 0 && p-i < 12 { + p = i + 1 + } + return text[:p], text[p:] +} + +func extra_for(width, screen_width int) int { + return utils.Max(0, screen_width-width)/2 + 1 +} + +func choices(o *Options, items []string) (ans map[string]any, err error) { + response := "" + lp, err := loop.New() + if err != nil { + return nil, err + } + lp.MouseTrackingMode(loop.BUTTONS_ONLY_MOUSE_TRACKING) + + prefix_style_pat := regexp.MustCompile("^(?:\x1b\\[[^m]*?m)+") + choice_order := make([]Choice, len(o.Choices)) + clickable_ranges := make(map[string][]Range, 16) + allowed := utils.NewSet[string](utils.Max(2, len(o.Choices))) + response_on_accept := o.Default + switch o.Type { + case "yesno": + allowed.AddItems("y", "n") + if !allowed.Has(response_on_accept) { + response_on_accept = "y" + } + case "choices": + first_choice := "" + for i, x := range o.Choices { + letter, text, _ := strings.Cut(x, ":") + color := "" + if strings.Contains(letter, ";") { + letter, color, _ = strings.Cut(letter, ";") + } + letter = strings.ToLower(letter) + idx := strings.Index(strings.ToLower(text), letter) + idx = len([]rune(strings.ToLower(text)[:idx])) + allowed.Add(letter) + c := Choice{text: text, idx: idx, color: color, letter: letter} + choice_order = append(choice_order, c) + if i == 0 { + first_choice = letter + } + } + if !allowed.Has(response_on_accept) { + response_on_accept = first_choice + } + } + message := o.Message + hidden_text_start_pos := -1 + hidden_text_end_pos := -1 + hidden_text := "" + m := markup.New(true) + replacement_text := fmt.Sprintf("Press %s or click to show", m.Green(o.UnhideKey)) + replacement_range := Range{-1, -1, -1} + if message != "" && o.HiddenTextPlaceholder != "" { + hidden_text_start_pos = strings.Index(message, o.HiddenTextPlaceholder) + if hidden_text_start_pos > -1 { + raw, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("Failed to read hidden text from STDIN: %w", err) + } + hidden_text = strings.TrimRightFunc(utils.UnsafeBytesToString(raw), unicode.IsSpace) + hidden_text_end_pos = hidden_text_start_pos + len(replacement_text) + suffix := message[hidden_text_start_pos+len(o.HiddenTextPlaceholder):] + message = message[:hidden_text_start_pos] + replacement_text + suffix + } + } + + draw_long_text := func(screen_width int, text string, msg_lines []string) []string { + if text == "" { + msg_lines = append(msg_lines, "") + } else { + width := screen_width - 2 + prefix := prefix_style_pat.FindString(text) + for text != "" { + var t string + t, text = truncate_at_space(text, width) + t = strings.TrimSpace(t) + msg_lines = append(msg_lines, strings.Repeat(" ", extra_for(wcswidth.Stringwidth(t), width))+m.Bold(prefix+t)) + } + } + return msg_lines + } + + ctx := style.Context{AllowEscapeCodes: true} + + draw_choice_boxes := func(y, screen_width, screen_height int, choices ...Choice) { + clickable_ranges = map[string][]Range{} + width := screen_width - 2 + current_line_length := 0 + type Item struct{ letter, text string } + type Line = []Item + var current_line Line + lines := make([]Line, 0, 32) + sep := " " + sep_sz := len(sep) + 2 // for the borders + + for _, choice := range choices { + clickable_ranges[choice.letter] = make([]Range, 0, 8) + text := " " + choice.prefix() + color := choice.color + if choice.color == "" { + color = "green" + } + text += ctx.SprintFunc("fg=" + color)(choice.display_letter()) + text += choice.suffix() + " " + sz := wcswidth.Stringwidth(text) + if sz+sep_sz+current_line_length > width { + lines = append(lines, current_line) + current_line = nil + current_line_length = 0 + } + current_line = append(current_line, Item{choice.letter, text}) + current_line_length += sz + sep_sz + } + if len(current_line) > 0 { + lines = append(lines, current_line) + } + + highlight := func(text string) string { + return m.Yellow(text) + } + + top := func(text string, highlight_frame bool) (ans string) { + ans = "╭" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╮" + if highlight_frame { + ans = highlight(ans) + } + return + } + + middle := func(text string, highlight_frame bool) (ans string) { + f := "│" + if highlight_frame { + f = highlight(f) + } + return f + text + f + } + + bottom := func(text string, highlight_frame bool) (ans string) { + ans = "╰" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╯" + if highlight_frame { + ans = highlight(ans) + } + return + } + + print_line := func(add_borders func(string, bool) string, is_last bool, items ...Item) { + type Position struct { + letter string + x, size int + } + texts := make([]string, 0, 8) + positions := make([]Position, 0, 8) + x := 0 + for _, item := range items { + text := item.text + positions = append(positions, Position{item.letter, x, wcswidth.Stringwidth(text) + 2}) + text = add_borders(text, item.letter == response_on_accept) + text += sep + x += wcswidth.Stringwidth(text) + texts = append(texts, text) + } + line := strings.TrimRightFunc(strings.Join(texts, ""), unicode.IsSpace) + offset := extra_for(wcswidth.Stringwidth(line), width) + for _, pos := range positions { + x += offset + clickable_ranges[pos.letter] = append(clickable_ranges[pos.letter], Range{x, x + pos.size - 1, y}) + } + end := "\r\n" + if is_last { + end = "" + } + lp.QueueWriteString(strings.Repeat(" ", offset) + line + end) + y++ + } + lp.AllowLineWrapping(false) + defer func() { lp.AllowLineWrapping(true) }() + for i, boxed_line := range lines { + print_line(top, false, boxed_line...) + print_line(middle, false, boxed_line...) + is_last := i == len(lines)-1 + print_line(bottom, is_last, boxed_line...) + } + } + + draw_yesno := func(y, screen_width, screen_height int) { + yes := m.Green("Y") + "es" + no := m.BrightRed("N") + "o" + if y+3 <= screen_height { + draw_choice_boxes(y, screen_width, screen_height, Choice{"Yes", 0, "green", "y"}, Choice{"No", 0, "red", "n"}) + } else { + sep := strings.Repeat(" ", 3) + text := yes + sep + no + w := wcswidth.Stringwidth(text) + x := extra_for(w, screen_width-2) + nx := x + wcswidth.Stringwidth(yes) + len(sep) + clickable_ranges = map[string][]Range{ + "y": {{x, x + wcswidth.Stringwidth(yes) - 1, y}}, + "n": {{nx, nx + wcswidth.Stringwidth(no) - 1, y}}, + } + lp.QueueWriteString(strings.Repeat(" ", x) + text) + } + } + + draw_choice := func(y, screen_width, screen_height int) { + if y+3 <= screen_height { + draw_choice_boxes(y, screen_width, screen_height, choice_order...) + return + } + clickable_ranges = map[string][]Range{} + current_line := "" + current_ranges := map[string]int{} + width := screen_width - 2 + + commit_line := func(add_newline bool) { + x := extra_for(wcswidth.Stringwidth(current_line), width) + text := strings.Repeat(" ", x) + current_line + if add_newline { + lp.Println(text) + } else { + lp.QueueWriteString(text) + } + for letter, sz := range current_ranges { + clickable_ranges[letter] = []Range{{x, x + sz - 3, y}} + x += sz + } + current_ranges = map[string]int{} + y++ + current_line = "" + } + for _, choice := range choice_order { + text := choice.prefix() + spec := "" + if choice.color != "" { + spec = "fg=" + choice.color + } else { + spec = "fg=green" + } + if choice.letter == response_on_accept { + spec += " u=straight" + } + text += ctx.SprintFunc(spec)(choice.display_letter()) + text += choice.suffix() + text += " " + sz := wcswidth.Stringwidth(text) + if sz+wcswidth.Stringwidth(current_line) >= width { + commit_line(true) + } + current_line += text + current_ranges[choice.letter] = sz + } + if current_line != "" { + commit_line(false) + } + } + + draw_screen := func() error { + lp.StartAtomicUpdate() + defer lp.EndAtomicUpdate() + lp.ClearScreen() + msg_lines := make([]string, 0, 8) + sz, err := lp.ScreenSize() + if err != nil { + return err + } + if message != "" { + scanner := bufio.NewScanner(strings.NewReader(message)) + for scanner.Scan() { + msg_lines = draw_long_text(int(sz.WidthCells), scanner.Text(), msg_lines) + } + } + y := int(sz.HeightCells) - len(msg_lines) + y = utils.Max(0, (y/2)-2) + lp.QueueWriteString(strings.Repeat("\r\n", y)) + for _, line := range msg_lines { + if replacement_text != "" { + idx := strings.Index(line, replacement_text) + if idx > -1 { + x := wcswidth.Stringwidth(line[:idx]) + replacement_range = Range{x, x + wcswidth.Stringwidth(replacement_text), y} + } + } + lp.Println(line) + y++ + } + if sz.HeightCells > 2 { + lp.Println() + y++ + } + switch o.Type { + case "yesno": + draw_yesno(y, int(sz.WidthCells), int(sz.HeightCells)) + case "choices": + draw_choice(y, int(sz.WidthCells), int(sz.HeightCells)) + } + return nil + } + + unhide := func() { + if hidden_text != "" && message != "" { + message = message[:hidden_text_start_pos] + hidden_text + message[hidden_text_end_pos:] + hidden_text = "" + draw_screen() + } + } + + lp.OnInitialize = func() (string, error) { + lp.SetCursorVisible(false) + return "", draw_screen() + } + + lp.OnFinalize = func() string { + lp.SetCursorVisible(true) + return "" + } + + lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error { + text = strings.ToLower(text) + if allowed.Has(text) { + response = text + lp.Quit(0) + } else if hidden_text != "" && text == o.UnhideKey { + unhide() + } else if o.Type == "yesno" { + lp.Quit(1) + } + return nil + } + + lp.OnKeyEvent = func(ev *loop.KeyEvent) error { + if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c") { + ev.Handled = true + lp.Quit(1) + } else if ev.MatchesPressOrRepeat("enter") { + ev.Handled = true + response = response_on_accept + lp.Quit(0) + } + return nil + } + + lp.OnMouseEvent = func(ev *loop.MouseEvent) error { + if ev.Event_type == loop.MOUSE_CLICK { + for letter, ranges := range clickable_ranges { + for _, r := range ranges { + if r.has_point(ev.Cell.X, ev.Cell.Y) { + response = letter + lp.Quit(0) + return nil + } + } + } + if hidden_text != "" && replacement_range.has_point(ev.Cell.X, ev.Cell.Y) { + unhide() + } + } + return nil + } + + lp.OnResize = func(old, news loop.ScreenSize) error { + return draw_screen() + } + + err = lp.Run() + if err != nil { + return nil, err + } + ds := lp.DeathSignalName() + if ds != "" { + fmt.Println("Killed by signal: ", ds) + lp.KillIfSignalled() + return nil, fmt.Errorf("Filled by signal: %s", ds) + } + ans = map[string]any{"items": items, "response": response} + return +} diff --git a/tools/cmd/ask/main.go b/tools/cmd/ask/main.go new file mode 100644 index 000000000..6e148f849 --- /dev/null +++ b/tools/cmd/ask/main.go @@ -0,0 +1,39 @@ +// License: GPLv3 Copyright: 2023, Kovid Goyal, + +package ask + +import ( + "fmt" + + "kitty/tools/cli" + "kitty/tools/tui" +) + +var _ = fmt.Print + +func main(_ *cli.Command, o *Options, args []string) (rc int, err error) { + output := tui.KittenOutputSerializer() + var result any + switch o.Type { + case "yesno", "choices": + result, err = choices(o, args) + if err != nil { + return rc, err + } + default: + return 1, fmt.Errorf("Unknown type: %s", o.Type) + } + s, err := output(result) + if err != nil { + return 1, err + } + _, err = fmt.Println(s) + if err != nil { + return 1, err + } + return +} + +func EntryPoint(parent *cli.Command) { + create_cmd(parent, main) +} diff --git a/tools/cmd/tool/main.go b/tools/cmd/tool/main.go index f722c4041..26f5907f4 100644 --- a/tools/cmd/tool/main.go +++ b/tools/cmd/tool/main.go @@ -6,6 +6,7 @@ import ( "fmt" "kitty/tools/cli" + "kitty/tools/cmd/ask" "kitty/tools/cmd/at" "kitty/tools/cmd/clipboard" "kitty/tools/cmd/edit_in_kitty" @@ -39,6 +40,8 @@ func KittyToolEntryPoints(root *cli.Command) { unicode_input.EntryPoint(root) // hyperlinked_grep hyperlinked_grep.EntryPoint(root) + // ask + ask.EntryPoint(root) // __pytest__ pytest.EntryPoint(root) // __hold_till_enter__