// License: GPLv3 Copyright: 2023, Kovid Goyal, package ask import ( "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 len(truncated) == 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 GetChoices(o *Options) (response string, err error) { response = "" lp, err := loop.New() if err != nil { return "", err } lp.MouseTrackingMode(loop.BUTTONS_ONLY_MOUSE_TRACKING) prefix_style_pat := regexp.MustCompile("^(?:\x1b\\[[^m]*?m)+") choice_order := make([]Choice, 0, 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 "", 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, 4) 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 = pos.x 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 := utils.NewLineScanner(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 "", err } ds := lp.DeathSignalName() if ds != "" { fmt.Println("Killed by signal: ", ds) lp.KillIfSignalled() return "", fmt.Errorf("Filled by signal: %s", ds) } return response, nil }