1package tui
2
3import (
4 "net/url"
5 "strings"
6
7 "github.com/mattn/go-runewidth"
8)
9
10// Editor is a simple multi-line text editor for the input area.
11//
12// Users can type, paste, move the cursor, and submit. The editor
13// exposes its rendered height and the current cursor row/col for the
14// outer renderer. It does NOT draw itself directly; instead, Render()
15// returns the visible lines.
16type Editor struct {
17 // Lines is the current buffer, one entry per line.
18 Lines []string
19 CursorR int // row index into Lines
20 CursorC int // rune index into Lines[CursorR]
21 Prompt string
22 MaxWidth int
23
24 // History is a ring of previously submitted buffers (newest last).
25 History []string
26 histIdx int // -1 means "editing current buffer"
27 savedDraft string
28}
29
30// NewEditor returns an empty editor with the given prompt.
31func NewEditor(prompt string) *Editor {
32 return &Editor{
33 Lines: []string{""},
34 Prompt: prompt,
35 histIdx: -1,
36 }
37}
38
39// Value returns the buffer as a single string.
40func (e *Editor) Value() string { return strings.Join(e.Lines, "\n") }
41
42// SetValue replaces the buffer and places the cursor at the end.
43func (e *Editor) SetValue(s string) {
44 e.Lines = strings.Split(s, "\n")
45 if len(e.Lines) == 0 {
46 e.Lines = []string{""}
47 }
48 e.CursorR = len(e.Lines) - 1
49 e.CursorC = runeLen(e.Lines[e.CursorR])
50 e.histIdx = -1
51}
52
53// Clear resets the buffer.
54func (e *Editor) Clear() { e.SetValue("") }
55
56// IsEmpty reports whether the buffer has no visible content.
57func (e *Editor) IsEmpty() bool {
58 return len(e.Lines) == 1 && e.Lines[0] == ""
59}
60
61// HandleKey applies k to the editor. It returns (submit, key).
62// If submit is true, the caller should read Value() and call
63// PushHistory + Clear.
64func (e *Editor) HandleKey(k Key) (submit bool) {
65 switch k.Kind {
66 case KeyRune:
67 if k.Alt && (k.Rune == '\r' || k.Rune == '\n') {
68 e.newline()
69 return false
70 }
71 e.insert(string(k.Rune))
72 case KeyEnter:
73 // Shift+Enter would be a separate key in some terminals; we treat
74 // the literal newline byte as Enter. Newline on submit is a decision
75 // for the outer UI via slash commands. Here Enter submits.
76 return true
77 case KeyBackspace:
78 if k.Alt {
79 // Alt+Backspace (Option+Delete on macOS) — delete previous word.
80 e.deleteWord()
81 } else {
82 e.backspace()
83 }
84 case KeyDelete:
85 e.delete()
86 case KeyLeft:
87 if k.Alt {
88 e.moveWordLeft()
89 } else {
90 e.moveLeft()
91 }
92 case KeyRight:
93 if k.Alt {
94 e.moveWordRight()
95 } else {
96 e.moveRight()
97 }
98 case KeyUp:
99 if e.CursorR == 0 {
100 e.historyPrev()
101 } else {
102 e.CursorR--
103 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
104 e.CursorC = runeLen(e.Lines[e.CursorR])
105 }
106 }
107 case KeyDown:
108 if e.CursorR == len(e.Lines)-1 {
109 e.historyNext()
110 } else {
111 e.CursorR++
112 if e.CursorC > runeLen(e.Lines[e.CursorR]) {
113 e.CursorC = runeLen(e.Lines[e.CursorR])
114 }
115 }
116 case KeyHome, KeyCtrlA:
117 e.CursorC = 0
118 case KeyEnd, KeyCtrlE:
119 e.CursorC = runeLen(e.Lines[e.CursorR])
120 case KeyCtrlU:
121 e.Lines[e.CursorR] = substringAfter(e.Lines[e.CursorR], e.CursorC)
122 e.CursorC = 0
123 case KeyCtrlK:
124 e.Lines[e.CursorR] = substringBefore(e.Lines[e.CursorR], e.CursorC)
125 case KeyCtrlW:
126 e.deleteWord()
127 case KeyPaste:
128 // macOS Terminal / iTerm / Ghostty deliver drag-dropped files
129 // as bracketed-paste text. Detect that pattern and wrap the
130 // path(s) in single quotes so the agent sees them as one
131 // argument and any spaces / parens in the filename don't
132 // confuse downstream tool calls.
133 e.insert(quotePastedFilePaths(k.Paste))
134 case KeyEsc:
135 e.Clear()
136 }
137 return false
138}
139
140// quotePastedFilePaths returns paste with any drag-dropped file
141// paths wrapped in single quotes. Heuristics:
142//
143// - one line only; multi-line text is returned unchanged so we
144// never touch a real paste of code.
145// - whitespace-separated tokens are inspected one by one. A token
146// that looks like a filesystem path (absolute, ~/relative, or
147// file:// URL) is normalised and re-quoted; everything else is
148// left exactly as the user dropped it.
149// - terminal-style backslash escapes ("foo\ bar.png") are folded
150// back to literal characters before quoting, since wrapping in
151// single quotes makes them unnecessary.
152// - file:// URLs are URL-decoded and stripped of the scheme so
153// the agent receives a plain filesystem path.
154// - if a path contains a literal single quote, it's escaped using
155// the standard '\” splice.
156//
157// Pastes that don't contain any path-shaped tokens are returned
158// unchanged.
159func quotePastedFilePaths(paste string) string {
160 if paste == "" || strings.ContainsRune(paste, '\n') {
161 return paste
162 }
163 trimmed := strings.TrimSpace(paste)
164 if trimmed == "" {
165 return paste
166 }
167
168 // Split on runs of whitespace, preserving the original separators
169 // so multi-file drops keep their spacing on rebuild. Backslash
170 // before a space is treated as part of the preceding token, since
171 // macOS Terminal escapes spaces in dropped paths that way.
172 tokens := splitPreservingSeparators(paste)
173 changed := false
174 for i, tk := range tokens {
175 if tk.isSpace {
176 continue
177 }
178 if p, ok := normalisePathToken(tk.text); ok {
179 tokens[i].text = singleQuote(p)
180 changed = true
181 }
182 }
183 if !changed {
184 return paste
185 }
186 var out strings.Builder
187 out.Grow(len(paste) + 8)
188 for _, tk := range tokens {
189 out.WriteString(tk.text)
190 }
191 return out.String()
192}
193
194type pasteToken struct {
195 text string
196 isSpace bool
197}
198
199// splitPreservingSeparators splits s into runs of whitespace and
200// runs of non-whitespace, keeping the separator runs intact so a
201// rebuild via concatenation reproduces the original string exactly.
202// A backslash immediately followed by a space ("\ ") is treated as
203// part of the preceding non-whitespace run, since that's how macOS
204// Terminal escapes spaces in drag-dropped file paths.
205func splitPreservingSeparators(s string) []pasteToken {
206 runes := []rune(s)
207 var out []pasteToken
208 var buf strings.Builder
209 inSpace := false
210 flush := func() {
211 if buf.Len() == 0 {
212 return
213 }
214 out = append(out, pasteToken{text: buf.String(), isSpace: inSpace})
215 buf.Reset()
216 }
217 for i := 0; i < len(runes); i++ {
218 r := runes[i]
219 isEscapedSpace := r == '\\' && i+1 < len(runes) && (runes[i+1] == ' ' || runes[i+1] == '\t')
220 rSpace := !isEscapedSpace && (r == ' ' || r == '\t')
221 if buf.Len() == 0 {
222 inSpace = rSpace
223 } else if rSpace != inSpace {
224 flush()
225 inSpace = rSpace
226 }
227 if isEscapedSpace {
228 // Skip the backslash; emit the literal space as part of
229 // this non-whitespace token. normalisePathToken's
230 // unescapeBackslashes is now redundant for this path but
231 // stays in case some other source escapes other chars.
232 buf.WriteRune(runes[i+1])
233 i++
234 continue
235 }
236 buf.WriteRune(r)
237 }
238 flush()
239 return out
240}
241
242// normalisePathToken decides whether tk is a drag-dropped path and,
243// if so, returns the cleaned-up path string.
244func normalisePathToken(tk string) (string, bool) {
245 // Strip pre-existing surrounding quotes; we'll re-quote consistently.
246 if n := len(tk); n >= 2 {
247 if (tk[0] == '\'' && tk[n-1] == '\'') || (tk[0] == '"' && tk[n-1] == '"') {
248 tk = tk[1 : n-1]
249 }
250 }
251
252 // file:// URL form: decode and strip the scheme.
253 if strings.HasPrefix(tk, "file://") {
254 decoded, err := url.PathUnescape(strings.TrimPrefix(tk, "file://"))
255 if err != nil {
256 return "", false
257 }
258 if decoded == "" || decoded[0] != '/' {
259 return "", false
260 }
261 return decoded, true
262 }
263
264 // Looks-like-a-path heuristic: starts with /, ~, ~/. Must contain
265 // at least one path separator after the prefix (otherwise a bare
266 // "/" or "~" gets quoted, which is never what the user meant).
267 if !strings.HasPrefix(tk, "/") && !strings.HasPrefix(tk, "~") {
268 return "", false
269 }
270 if !strings.ContainsAny(tk[1:], "/.") {
271 return "", false
272 }
273 unescaped := unescapeBackslashes(tk)
274 if strings.ContainsAny(unescaped, "|;&$`<>") {
275 return "", false
276 }
277 return unescaped, true
278}
279
280// unescapeBackslashes turns "foo\ bar" into "foo bar". Terminal
281// drag-and-drop on macOS uses backslash escaping by default; quoting
282// makes that unnecessary.
283func unescapeBackslashes(s string) string {
284 if !strings.Contains(s, `\`) {
285 return s
286 }
287 var out strings.Builder
288 out.Grow(len(s))
289 prev := false
290 for _, r := range s {
291 if prev {
292 out.WriteRune(r)
293 prev = false
294 continue
295 }
296 if r == '\\' {
297 prev = true
298 continue
299 }
300 out.WriteRune(r)
301 }
302 if prev {
303 out.WriteRune('\\')
304 }
305 return out.String()
306}
307
308// singleQuote wraps s in single quotes, escaping any embedded single
309// quote using the splice idiom: 'foo'\”bar' for foo'bar.
310func singleQuote(s string) string {
311 return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
312}
313
314func (e *Editor) insert(s string) {
315 e.histIdx = -1
316 line := e.Lines[e.CursorR]
317 pre := substringBefore(line, e.CursorC)
318 post := substringAfter(line, e.CursorC)
319 // Split pasted text on newlines.
320 parts := strings.Split(s, "\n")
321 if len(parts) == 1 {
322 e.Lines[e.CursorR] = pre + s + post
323 e.CursorC += runeLen(s)
324 return
325 }
326 newLines := make([]string, 0, len(e.Lines)+len(parts)-1)
327 newLines = append(newLines, e.Lines[:e.CursorR]...)
328 newLines = append(newLines, pre+parts[0])
329 for i := 1; i < len(parts)-1; i++ {
330 newLines = append(newLines, parts[i])
331 }
332 last := parts[len(parts)-1]
333 newLines = append(newLines, last+post)
334 newLines = append(newLines, e.Lines[e.CursorR+1:]...)
335 e.Lines = newLines
336 e.CursorR += len(parts) - 1
337 e.CursorC = runeLen(last)
338}
339
340func (e *Editor) newline() {
341 e.histIdx = -1
342 line := e.Lines[e.CursorR]
343 pre := substringBefore(line, e.CursorC)
344 post := substringAfter(line, e.CursorC)
345 e.Lines[e.CursorR] = pre
346 e.Lines = append(e.Lines, "")
347 copy(e.Lines[e.CursorR+2:], e.Lines[e.CursorR+1:])
348 e.Lines[e.CursorR+1] = post
349 e.CursorR++
350 e.CursorC = 0
351}
352
353func (e *Editor) backspace() {
354 e.histIdx = -1
355 if e.CursorC == 0 {
356 if e.CursorR == 0 {
357 return
358 }
359 prev := e.Lines[e.CursorR-1]
360 cur := e.Lines[e.CursorR]
361 e.Lines = append(e.Lines[:e.CursorR], e.Lines[e.CursorR+1:]...)
362 e.CursorR--
363 e.CursorC = runeLen(prev)
364 e.Lines[e.CursorR] = prev + cur
365 return
366 }
367 line := e.Lines[e.CursorR]
368 e.Lines[e.CursorR] = substringBefore(line, e.CursorC-1) + substringAfter(line, e.CursorC)
369 e.CursorC--
370}
371
372func (e *Editor) delete() {
373 e.histIdx = -1
374 line := e.Lines[e.CursorR]
375 if e.CursorC == runeLen(line) {
376 if e.CursorR == len(e.Lines)-1 {
377 return
378 }
379 next := e.Lines[e.CursorR+1]
380 e.Lines = append(e.Lines[:e.CursorR+1], e.Lines[e.CursorR+2:]...)
381 e.Lines[e.CursorR] = line + next
382 return
383 }
384 e.Lines[e.CursorR] = substringBefore(line, e.CursorC) + substringAfter(line, e.CursorC+1)
385}
386
387// moveWordLeft jumps the cursor to the start of the previous word,
388// using the same word-separator rules as deleteWord. If already at the
389// start of the line, wraps to the end of the previous line.
390func (e *Editor) moveWordLeft() {
391 if e.CursorC == 0 {
392 if e.CursorR > 0 {
393 e.CursorR--
394 e.CursorC = runeLen(e.Lines[e.CursorR])
395 }
396 return
397 }
398 r := []rune(e.Lines[e.CursorR])
399 i := e.CursorC
400 for i > 0 && isWordSep(r[i-1]) {
401 i--
402 }
403 for i > 0 && !isWordSep(r[i-1]) {
404 i--
405 }
406 e.CursorC = i
407}
408
409// moveWordRight jumps the cursor to the start of the next word. If
410// already at the end of the line, wraps to the start of the next line.
411func (e *Editor) moveWordRight() {
412 line := e.Lines[e.CursorR]
413 if e.CursorC >= runeLen(line) {
414 if e.CursorR < len(e.Lines)-1 {
415 e.CursorR++
416 e.CursorC = 0
417 }
418 return
419 }
420 r := []rune(line)
421 i := e.CursorC
422 for i < len(r) && !isWordSep(r[i]) {
423 i++
424 }
425 for i < len(r) && isWordSep(r[i]) {
426 i++
427 }
428 e.CursorC = i
429}
430
431func (e *Editor) moveLeft() {
432 if e.CursorC > 0 {
433 e.CursorC--
434 return
435 }
436 if e.CursorR > 0 {
437 e.CursorR--
438 e.CursorC = runeLen(e.Lines[e.CursorR])
439 }
440}
441
442func (e *Editor) moveRight() {
443 if e.CursorC < runeLen(e.Lines[e.CursorR]) {
444 e.CursorC++
445 return
446 }
447 if e.CursorR < len(e.Lines)-1 {
448 e.CursorR++
449 e.CursorC = 0
450 }
451}
452
453func (e *Editor) deleteWord() {
454 e.histIdx = -1
455 line := e.Lines[e.CursorR]
456 if e.CursorC == 0 {
457 e.backspace()
458 return
459 }
460 r := []rune(line)
461 i := e.CursorC
462 // Step 1: walk over any trailing whitespace to the left of the cursor.
463 for i > 0 && isWordSep(r[i-1]) {
464 i--
465 }
466 // Step 2: walk over the word itself (non-separator runes).
467 for i > 0 && !isWordSep(r[i-1]) {
468 i--
469 }
470 e.Lines[e.CursorR] = string(r[:i]) + string(r[e.CursorC:])
471 e.CursorC = i
472}
473
474// isWordSep reports whether r is a word separator. Anything that isn't
475// a letter, digit, or underscore counts as a separator so delete-word
476// feels natural on paths, code, and prose.
477func isWordSep(r rune) bool {
478 switch {
479 case r == '_':
480 return false
481 case r >= '0' && r <= '9':
482 return false
483 case r >= 'a' && r <= 'z':
484 return false
485 case r >= 'A' && r <= 'Z':
486 return false
487 case r >= 0x80:
488 // Treat all non-ASCII as word chars so CJK/äöü don't get chopped.
489 return false
490 }
491 return true
492}
493
494// ---- history ----
495
496// PushHistory saves s to the history ring.
497func (e *Editor) PushHistory(s string) {
498 s = strings.TrimRight(s, "\n")
499 if s == "" {
500 return
501 }
502 if n := len(e.History); n > 0 && e.History[n-1] == s {
503 return
504 }
505 e.History = append(e.History, s)
506 if len(e.History) > 200 {
507 e.History = e.History[len(e.History)-200:]
508 }
509 e.histIdx = -1
510}
511
512func (e *Editor) historyPrev() {
513 if len(e.History) == 0 {
514 return
515 }
516 if e.histIdx == -1 {
517 e.savedDraft = e.Value()
518 e.histIdx = len(e.History) - 1
519 } else if e.histIdx > 0 {
520 e.histIdx--
521 }
522 e.SetValue(e.History[e.histIdx])
523 // SetValue resets histIdx; restore.
524 e.histIdx = e.findInHistory()
525}
526
527func (e *Editor) historyNext() {
528 if e.histIdx == -1 {
529 return
530 }
531 if e.histIdx == len(e.History)-1 {
532 e.histIdx = -1
533 e.SetValue(e.savedDraft)
534 e.histIdx = -1
535 return
536 }
537 e.histIdx++
538 e.SetValue(e.History[e.histIdx])
539 e.histIdx = e.findInHistory()
540}
541
542func (e *Editor) findInHistory() int {
543 v := e.Value()
544 for i, h := range e.History {
545 if h == v {
546 return i
547 }
548 }
549 return -1
550}
551
552// ---- rendering ----
553
554// Render returns the editor's visible lines (wrapped to width).
555// visualRow/visualCol describe where the cursor lands within the returned lines.
556func (e *Editor) Render(width int) (lines []string, visualRow, visualCol int) {
557 promptLen := visibleWidth(e.Prompt)
558 indent := strings.Repeat(" ", promptLen)
559
560 for r, line := range e.Lines {
561 var prefix string
562 if r == 0 {
563 prefix = e.Prompt
564 } else {
565 prefix = indent
566 }
567 wrapped := wrapLine(prefix+line, width, indent)
568 if r == e.CursorR {
569 // Compute where CursorC lands inside the wrapped rows by
570 // walking the wrapped output character-by-character and
571 // tracking which wrapped row + column corresponds to the
572 // cursor's rune index in `line`. This is the only reliable
573 // answer under word-wrap, where a simple (promptLen+col)/width
574 // formula overshoots when wrapLine broke on a space.
575 targetRunes := e.CursorC
576 row, col := locateCursor(wrapped, prefix, line, targetRunes, indent)
577 visualRow = len(lines) + row
578 visualCol = col
579 }
580 lines = append(lines, wrapped...)
581 }
582 return lines, visualRow, visualCol
583}
584
585// locateCursor finds the wrapped row + visible column corresponding to
586// `targetRunes` rune positions into the logical `line`, given that the
587// wrapped output `wrapped` started with `prefix` on its first row and
588// uses `cont` as the continuation indent on subsequent rows.
589func locateCursor(wrapped []string, prefix, line string, targetRunes int, cont string) (int, int) {
590 prefixW := visibleWidth(prefix)
591 contW := visibleWidth(cont)
592 lineRunes := []rune(line)
593 if targetRunes > len(lineRunes) {
594 targetRunes = len(lineRunes)
595 }
596
597 seenRunes := 0
598 for row, w := range wrapped {
599 // strip leading prefix / continuation indent before counting runes
600 body := w
601 var leadW int
602 if row == 0 {
603 if strings.HasPrefix(body, prefix) {
604 body = body[len(prefix):]
605 }
606 leadW = prefixW
607 } else {
608 if strings.HasPrefix(body, cont) {
609 body = body[len(cont):]
610 }
611 leadW = contW
612 }
613 bodyRunes := []rune(body)
614 // Could this wrapped row contain the cursor?
615 if targetRunes <= seenRunes+len(bodyRunes) {
616 // Column inside body.
617 inner := targetRunes - seenRunes
618 if inner < 0 {
619 inner = 0
620 }
621 col := leadW + runewidth.StringWidth(string(bodyRunes[:inner]))
622 return row, col
623 }
624 seenRunes += len(bodyRunes)
625 // Word-wrap may have dropped whitespace at the boundary; skip the
626 // corresponding runes in `line` so counts stay aligned.
627 for seenRunes < len(lineRunes) && lineRunes[seenRunes] == ' ' {
628 seenRunes++
629 if seenRunes >= targetRunes {
630 // Cursor landed in the skipped whitespace — place it at
631 // the start of the next wrapped row.
632 nextRow := row + 1
633 if nextRow >= len(wrapped) {
634 nextRow = row
635 }
636 return nextRow, contW
637 }
638 }
639 }
640 // Fallback: end of the last wrapped row.
641 if len(wrapped) == 0 {
642 return 0, prefixW
643 }
644 last := wrapped[len(wrapped)-1]
645 return len(wrapped) - 1, visibleWidth(last)
646}
647
648// ---- helpers ----
649
650func substringBefore(s string, col int) string {
651 r := []rune(s)
652 if col > len(r) {
653 col = len(r)
654 }
655 return string(r[:col])
656}
657
658func substringAfter(s string, col int) string {
659 r := []rune(s)
660 if col > len(r) {
661 col = len(r)
662 }
663 return string(r[col:])
664}
665
666func runeLen(s string) int { return len([]rune(s)) }
667
668func visualColumn(s string, runeCol int) int {
669 r := []rune(s)
670 if runeCol > len(r) {
671 runeCol = len(r)
672 }
673 return runewidth.StringWidth(string(r[:runeCol]))
674}
675
676func visibleWidth(s string) int {
677 return runewidth.StringWidth(stripANSI(s))
678}
679
680func stripANSI(s string) string {
681 // Minimal ANSI stripper; handles CSI sequences (ESC [ ... final).
682 var out []rune
683 i := 0
684 runes := []rune(s)
685 for i < len(runes) {
686 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
687 i += 2
688 for i < len(runes) {
689 c := runes[i]
690 i++
691 if c >= 0x40 && c <= 0x7e {
692 break
693 }
694 }
695 continue
696 }
697 out = append(out, runes[i])
698 i++
699 }
700 return string(out)
701}
702
703func wrapLine(s string, width int, cont string) []string {
704 if width <= 0 {
705 return []string{s}
706 }
707
708 // Tokenize on spaces while preserving their widths. Runs of spaces
709 // stay attached to the preceding word so trailing whitespace doesn't
710 // create empty wrapped lines.
711 type token struct {
712 text string
713 width int
714 space bool // true if this token is whitespace only
715 }
716 var tokens []token
717 {
718 var buf strings.Builder
719 var bufW int
720 inSpace := false
721 flush := func() {
722 if buf.Len() == 0 {
723 return
724 }
725 tokens = append(tokens, token{text: buf.String(), width: bufW, space: inSpace})
726 buf.Reset()
727 bufW = 0
728 }
729 for _, r := range s {
730 rSpace := r == ' ' || r == '\t'
731 if buf.Len() == 0 {
732 inSpace = rSpace
733 } else if rSpace != inSpace {
734 flush()
735 inSpace = rSpace
736 }
737 buf.WriteRune(r)
738 bufW += runewidth.RuneWidth(r)
739 }
740 flush()
741 }
742
743 var out []string
744 var cur strings.Builder
745 curW := 0
746 firstLine := true
747 contW := visibleWidth(cont)
748
749 newLine := func() {
750 out = append(out, cur.String())
751 cur.Reset()
752 curW = 0
753 if !firstLine {
754 cur.WriteString(cont)
755 curW = contW
756 }
757 firstLine = false
758 }
759
760 for i := 0; i < len(tokens); i++ {
761 tk := tokens[i]
762
763 // Drop leading whitespace after a wrap.
764 if tk.space && curW == contW && !firstLine {
765 continue
766 }
767
768 if curW+tk.width <= width {
769 cur.WriteString(tk.text)
770 curW += tk.width
771 continue
772 }
773
774 // Token overflows. If current line has content, break first.
775 if cur.Len() > 0 && !(firstLine && curW == 0) {
776 // Drop trailing whitespace on the line we're about to break.
777 trimmed := strings.TrimRight(cur.String(), " \t")
778 cur.Reset()
779 cur.WriteString(trimmed)
780 curW = visibleWidth(trimmed)
781 newLine()
782 // Re-process this token on the fresh line.
783 if tk.space {
784 continue
785 }
786 }
787
788 // Token is longer than the available width on an empty line.
789 // Split the token itself rune-by-rune.
790 if tk.width > width-curW {
791 for _, r := range tk.text {
792 rw := runewidth.RuneWidth(r)
793 if curW+rw > width {
794 newLine()
795 }
796 cur.WriteRune(r)
797 curW += rw
798 }
799 } else {
800 cur.WriteString(tk.text)
801 curW += tk.width
802 }
803 }
804
805 if cur.Len() > 0 || len(out) == 0 {
806 out = append(out, cur.String())
807 }
808 return out
809}
810
811func max(a, b int) int {
812 if a > b {
813 return a
814 }
815 return b
816}