choices.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. // License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
  2. package ask
  3. import (
  4. "fmt"
  5. "io"
  6. "kitty/tools/cli/markup"
  7. "kitty/tools/tty"
  8. "kitty/tools/tui/loop"
  9. "kitty/tools/utils"
  10. "kitty/tools/utils/style"
  11. "kitty/tools/wcswidth"
  12. "os"
  13. "regexp"
  14. "strings"
  15. "unicode"
  16. )
  17. var _ = fmt.Print
  18. type Choice struct {
  19. text string
  20. idx int
  21. color, letter string
  22. }
  23. func (self Choice) prefix() string {
  24. return string([]rune(self.text)[:self.idx])
  25. }
  26. func (self Choice) display_letter() string {
  27. return string([]rune(self.text)[self.idx])
  28. }
  29. func (self Choice) suffix() string {
  30. return string([]rune(self.text)[self.idx+1:])
  31. }
  32. type Range struct {
  33. start, end, y int
  34. }
  35. func (self *Range) has_point(x, y int) bool {
  36. return y == self.y && self.start <= x && x <= self.end
  37. }
  38. func truncate_at_space(text string, width int) (string, string) {
  39. truncated, p := wcswidth.TruncateToVisualLengthWithWidth(text, width)
  40. if len(truncated) == len(text) {
  41. return text, ""
  42. }
  43. i := strings.LastIndexByte(truncated, ' ')
  44. if i > 0 && p-i < 12 {
  45. p = i + 1
  46. }
  47. return text[:p], text[p:]
  48. }
  49. func extra_for(width, screen_width int) int {
  50. return max(0, screen_width-width)/2 + 1
  51. }
  52. var debugprintln = tty.DebugPrintln
  53. var _ = debugprintln
  54. func GetChoices(o *Options) (response string, err error) {
  55. response = ""
  56. lp, err := loop.New()
  57. if err != nil {
  58. return "", err
  59. }
  60. lp.MouseTrackingMode(loop.FULL_MOUSE_TRACKING)
  61. prefix_style_pat := regexp.MustCompile("^(?:\x1b\\[[^m]*?m)+")
  62. choice_order := make([]Choice, 0, len(o.Choices))
  63. clickable_ranges := make(map[string][]Range, 16)
  64. allowed := utils.NewSet[string](max(2, len(o.Choices)))
  65. response_on_accept := o.Default
  66. switch o.Type {
  67. case "yesno":
  68. allowed.AddItems("y", "n")
  69. if !allowed.Has(response_on_accept) {
  70. response_on_accept = "y"
  71. }
  72. case "choices":
  73. first_choice := ""
  74. for i, x := range o.Choices {
  75. letter, text, _ := strings.Cut(x, ":")
  76. color := ""
  77. if strings.Contains(letter, ";") {
  78. letter, color, _ = strings.Cut(letter, ";")
  79. }
  80. letter = strings.ToLower(letter)
  81. idx := strings.Index(strings.ToLower(text), letter)
  82. if idx < 0 {
  83. return "", fmt.Errorf("The choice letter %#v is not present in the choice text: %#v", letter, text)
  84. }
  85. idx = len([]rune(strings.ToLower(text)[:idx]))
  86. allowed.Add(letter)
  87. c := Choice{text: text, idx: idx, color: color, letter: letter}
  88. choice_order = append(choice_order, c)
  89. if i == 0 {
  90. first_choice = letter
  91. }
  92. }
  93. if !allowed.Has(response_on_accept) {
  94. response_on_accept = first_choice
  95. }
  96. }
  97. message := o.Message
  98. hidden_text_start_pos := -1
  99. hidden_text_end_pos := -1
  100. hidden_text := ""
  101. m := markup.New(true)
  102. replacement_text := fmt.Sprintf("Press %s or click to show", m.Green(o.UnhideKey))
  103. replacement_range := Range{-1, -1, -1}
  104. if message != "" && o.HiddenTextPlaceholder != "" {
  105. hidden_text_start_pos = strings.Index(message, o.HiddenTextPlaceholder)
  106. if hidden_text_start_pos > -1 {
  107. raw, err := io.ReadAll(os.Stdin)
  108. if err != nil {
  109. return "", fmt.Errorf("Failed to read hidden text from STDIN: %w", err)
  110. }
  111. hidden_text = strings.TrimRightFunc(utils.UnsafeBytesToString(raw), unicode.IsSpace)
  112. hidden_text_end_pos = hidden_text_start_pos + len(replacement_text)
  113. suffix := message[hidden_text_start_pos+len(o.HiddenTextPlaceholder):]
  114. message = message[:hidden_text_start_pos] + replacement_text + suffix
  115. }
  116. }
  117. draw_long_text := func(screen_width int, text string, msg_lines []string) []string {
  118. if screen_width < 3 {
  119. return msg_lines
  120. }
  121. if text == "" {
  122. msg_lines = append(msg_lines, "")
  123. } else {
  124. width := screen_width - 2
  125. prefix := prefix_style_pat.FindString(text)
  126. for text != "" {
  127. var t string
  128. t, text = truncate_at_space(text, width)
  129. t = strings.TrimSpace(t)
  130. msg_lines = append(msg_lines, strings.Repeat(" ", extra_for(wcswidth.Stringwidth(t), width))+m.Bold(prefix+t))
  131. }
  132. }
  133. return msg_lines
  134. }
  135. ctx := style.Context{AllowEscapeCodes: true}
  136. draw_choice_boxes := func(y, screen_width, _ int, choices ...Choice) {
  137. clickable_ranges = map[string][]Range{}
  138. width := screen_width - 2
  139. current_line_length := 0
  140. type Item struct{ letter, text string }
  141. type Line = []Item
  142. var current_line Line
  143. lines := make([]Line, 0, 32)
  144. sep := " "
  145. sep_sz := len(sep) + 2 // for the borders
  146. for _, choice := range choices {
  147. clickable_ranges[choice.letter] = make([]Range, 0, 4)
  148. text := " " + choice.prefix()
  149. color := choice.color
  150. if choice.color == "" {
  151. color = "green"
  152. }
  153. text += ctx.SprintFunc("fg=" + color)(choice.display_letter())
  154. text += choice.suffix() + " "
  155. sz := wcswidth.Stringwidth(text)
  156. if sz+sep_sz+current_line_length > width {
  157. lines = append(lines, current_line)
  158. current_line = nil
  159. current_line_length = 0
  160. }
  161. current_line = append(current_line, Item{choice.letter, text})
  162. current_line_length += sz + sep_sz
  163. }
  164. if len(current_line) > 0 {
  165. lines = append(lines, current_line)
  166. }
  167. highlight := func(text string) string {
  168. return m.Yellow(text)
  169. }
  170. top := func(text string, highlight_frame bool) (ans string) {
  171. ans = "╭" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╮"
  172. if highlight_frame {
  173. ans = highlight(ans)
  174. }
  175. return
  176. }
  177. middle := func(text string, highlight_frame bool) (ans string) {
  178. f := "│"
  179. if highlight_frame {
  180. f = highlight(f)
  181. }
  182. return f + text + f
  183. }
  184. bottom := func(text string, highlight_frame bool) (ans string) {
  185. ans = "╰" + strings.Repeat("─", wcswidth.Stringwidth(text)) + "╯"
  186. if highlight_frame {
  187. ans = highlight(ans)
  188. }
  189. return
  190. }
  191. print_line := func(add_borders func(string, bool) string, is_last bool, items ...Item) {
  192. type Position struct {
  193. letter string
  194. x, size int
  195. }
  196. texts := make([]string, 0, 8)
  197. positions := make([]Position, 0, 8)
  198. x := 0
  199. for _, item := range items {
  200. text := item.text
  201. positions = append(positions, Position{item.letter, x, wcswidth.Stringwidth(text) + 2})
  202. text = add_borders(text, item.letter == response_on_accept)
  203. text += sep
  204. x += wcswidth.Stringwidth(text)
  205. texts = append(texts, text)
  206. }
  207. line := strings.TrimRightFunc(strings.Join(texts, ""), unicode.IsSpace)
  208. offset := extra_for(wcswidth.Stringwidth(line), width)
  209. for _, pos := range positions {
  210. x = pos.x
  211. x += offset
  212. clickable_ranges[pos.letter] = append(clickable_ranges[pos.letter], Range{x, x + pos.size - 1, y})
  213. }
  214. end := "\r\n"
  215. if is_last {
  216. end = ""
  217. }
  218. lp.QueueWriteString(strings.Repeat(" ", offset) + line + end)
  219. y++
  220. }
  221. lp.AllowLineWrapping(false)
  222. defer func() { lp.AllowLineWrapping(true) }()
  223. for i, boxed_line := range lines {
  224. print_line(top, false, boxed_line...)
  225. print_line(middle, false, boxed_line...)
  226. is_last := i == len(lines)-1
  227. print_line(bottom, is_last, boxed_line...)
  228. }
  229. }
  230. draw_yesno := func(y, screen_width, screen_height int) {
  231. yes := m.Green("Y") + "es"
  232. no := m.BrightRed("N") + "o"
  233. if y+3 <= screen_height {
  234. draw_choice_boxes(y, screen_width, screen_height, Choice{"Yes", 0, "green", "y"}, Choice{"No", 0, "red", "n"})
  235. } else {
  236. sep := strings.Repeat(" ", 3)
  237. text := yes + sep + no
  238. w := wcswidth.Stringwidth(text)
  239. x := extra_for(w, screen_width-2)
  240. nx := x + wcswidth.Stringwidth(yes) + len(sep)
  241. clickable_ranges = map[string][]Range{
  242. "y": {{x, x + wcswidth.Stringwidth(yes) - 1, y}},
  243. "n": {{nx, nx + wcswidth.Stringwidth(no) - 1, y}},
  244. }
  245. lp.QueueWriteString(strings.Repeat(" ", x) + text)
  246. }
  247. }
  248. draw_choice := func(y, screen_width, screen_height int) {
  249. if y+3 <= screen_height {
  250. draw_choice_boxes(y, screen_width, screen_height, choice_order...)
  251. return
  252. }
  253. clickable_ranges = map[string][]Range{}
  254. current_line := ""
  255. current_ranges := map[string]int{}
  256. width := screen_width - 2
  257. commit_line := func(add_newline bool) {
  258. x := extra_for(wcswidth.Stringwidth(current_line), width)
  259. text := strings.Repeat(" ", x) + current_line
  260. if add_newline {
  261. lp.Println(text)
  262. } else {
  263. lp.QueueWriteString(text)
  264. }
  265. for letter, sz := range current_ranges {
  266. clickable_ranges[letter] = []Range{{x, x + sz - 3, y}}
  267. x += sz
  268. }
  269. current_ranges = map[string]int{}
  270. y++
  271. current_line = ""
  272. }
  273. for _, choice := range choice_order {
  274. text := choice.prefix()
  275. spec := ""
  276. if choice.color != "" {
  277. spec = "fg=" + choice.color
  278. } else {
  279. spec = "fg=green"
  280. }
  281. if choice.letter == response_on_accept {
  282. spec += " u=straight"
  283. }
  284. text += ctx.SprintFunc(spec)(choice.display_letter())
  285. text += choice.suffix()
  286. text += " "
  287. sz := wcswidth.Stringwidth(text)
  288. if sz+wcswidth.Stringwidth(current_line) >= width {
  289. commit_line(true)
  290. }
  291. current_line += text
  292. current_ranges[choice.letter] = sz
  293. }
  294. if current_line != "" {
  295. commit_line(false)
  296. }
  297. }
  298. draw_screen := func() error {
  299. lp.StartAtomicUpdate()
  300. defer lp.EndAtomicUpdate()
  301. lp.ClearScreen()
  302. msg_lines := make([]string, 0, 8)
  303. sz, err := lp.ScreenSize()
  304. if err != nil {
  305. return err
  306. }
  307. if message != "" {
  308. scanner := utils.NewLineScanner(message)
  309. for scanner.Scan() {
  310. msg_lines = draw_long_text(int(sz.WidthCells), scanner.Text(), msg_lines)
  311. }
  312. }
  313. y := int(sz.HeightCells) - len(msg_lines)
  314. y = max(0, (y/2)-2)
  315. lp.QueueWriteString(strings.Repeat("\r\n", y))
  316. for _, line := range msg_lines {
  317. if replacement_text != "" {
  318. idx := strings.Index(line, replacement_text)
  319. if idx > -1 {
  320. x := wcswidth.Stringwidth(line[:idx])
  321. replacement_range = Range{x, x + wcswidth.Stringwidth(replacement_text), y}
  322. }
  323. }
  324. lp.Println(line)
  325. y++
  326. }
  327. if sz.HeightCells > 2 {
  328. lp.Println()
  329. y++
  330. }
  331. switch o.Type {
  332. case "yesno":
  333. draw_yesno(y, int(sz.WidthCells), int(sz.HeightCells))
  334. case "choices":
  335. draw_choice(y, int(sz.WidthCells), int(sz.HeightCells))
  336. }
  337. return nil
  338. }
  339. unhide := func() {
  340. if hidden_text != "" && message != "" {
  341. message = message[:hidden_text_start_pos] + hidden_text + message[hidden_text_end_pos:]
  342. hidden_text = ""
  343. _ = draw_screen()
  344. }
  345. }
  346. lp.OnInitialize = func() (string, error) {
  347. lp.SetCursorVisible(false)
  348. if o.Title != "" {
  349. lp.SetWindowTitle(o.Title)
  350. }
  351. return "", draw_screen()
  352. }
  353. lp.OnFinalize = func() string {
  354. lp.SetCursorVisible(true)
  355. return ""
  356. }
  357. lp.OnText = func(text string, from_key_event, in_bracketed_paste bool) error {
  358. text = strings.ToLower(text)
  359. if allowed.Has(text) {
  360. response = text
  361. lp.Quit(0)
  362. } else if hidden_text != "" && text == o.UnhideKey {
  363. unhide()
  364. } else if o.Type == "yesno" {
  365. lp.Quit(1)
  366. }
  367. return nil
  368. }
  369. lp.OnKeyEvent = func(ev *loop.KeyEvent) error {
  370. if ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("ctrl+c") {
  371. ev.Handled = true
  372. lp.Quit(1)
  373. } else if ev.MatchesPressOrRepeat("enter") || ev.MatchesPressOrRepeat("kp_enter") {
  374. ev.Handled = true
  375. response = response_on_accept
  376. lp.Quit(0)
  377. }
  378. return nil
  379. }
  380. lp.OnMouseEvent = func(ev *loop.MouseEvent) error {
  381. on_letter := ""
  382. for letter, ranges := range clickable_ranges {
  383. for _, r := range ranges {
  384. if r.has_point(ev.Cell.X, ev.Cell.Y) {
  385. on_letter = letter
  386. break
  387. }
  388. }
  389. }
  390. if on_letter != "" {
  391. if s, has_shape := lp.CurrentPointerShape(); !has_shape && s != loop.POINTER_POINTER {
  392. lp.PushPointerShape(loop.POINTER_POINTER)
  393. }
  394. } else {
  395. if _, has_shape := lp.CurrentPointerShape(); has_shape {
  396. lp.PopPointerShape()
  397. }
  398. }
  399. if ev.Event_type == loop.MOUSE_CLICK {
  400. if on_letter != "" {
  401. response = on_letter
  402. lp.Quit(0)
  403. return nil
  404. }
  405. if hidden_text != "" && replacement_range.has_point(ev.Cell.X, ev.Cell.Y) {
  406. unhide()
  407. }
  408. }
  409. return nil
  410. }
  411. lp.OnResize = func(old, news loop.ScreenSize) error {
  412. return draw_screen()
  413. }
  414. err = lp.Run()
  415. if err != nil {
  416. return "", err
  417. }
  418. ds := lp.DeathSignalName()
  419. if ds != "" {
  420. fmt.Println("Killed by signal: ", ds)
  421. lp.KillIfSignalled()
  422. return "", fmt.Errorf("Filled by signal: %s", ds)
  423. }
  424. return response, nil
  425. }