1package tui
2
3import (
4 "strings"
5 "unicode"
6 "unicode/utf8"
7
8 "github.com/mattn/go-runewidth"
9)
10
11// Tab stops used when expanding tabs. Code uses 4 columns so Go and
12// Makefiles stay compact; shell output keeps the terminal's 8 so tables
13// printed by commands such as ls or column stay aligned.
14const (
15 codeTabStop = 4
16 terminalTabStop = 8
17)
18
19// Text from models, tools, and paste is untrusted terminal input. Layout
20// code measures rows with visibleWidth and pads box edges from that
21// measurement, so every such string must pass through sanitizeText or
22// sanitizeLine before layout. Tabs, carriage returns, escape sequences, and
23// other controls move the real cursor differently from what visibleWidth
24// counts, which misplaces box edges and leaves stale cells on screen.
25
26// splitLines splits s into rows, treating CRLF and lone CR as line breaks
27// so carriage-return progress output becomes separate rows.
28func splitLines(s string) []string {
29 if strings.IndexByte(s, '\r') >= 0 {
30 s = strings.ReplaceAll(s, "\r\n", "\n")
31 s = strings.ReplaceAll(s, "\r", "\n")
32 }
33 return strings.Split(s, "\n")
34}
35
36// sanitizeText returns s with every row passed through sanitizeLine.
37// Line breaks (LF, CRLF, CR) are kept as "\n".
38func sanitizeText(s string, tabStop int) string {
39 if isPlainText(s, true) {
40 return s
41 }
42 lines := splitLines(s)
43 for i, l := range lines {
44 lines[i] = sanitizeLine(l, tabStop)
45 }
46 return strings.Join(lines, "\n")
47}
48
49// sanitizeLine makes one row safe to measure and draw: tabs expand to
50// spaces at tabStop columns, line breaks become spaces, and escape
51// sequences, other control characters, and invalid UTF-8 are dropped.
52// Styling is applied by the renderer afterwards, so no incoming escape
53// sequence is kept.
54func sanitizeLine(s string, tabStop int) string {
55 if isPlainText(s, false) {
56 return s
57 }
58 var b strings.Builder
59 b.Grow(len(s))
60 col := 0
61 for i := 0; i < len(s); {
62 if s[i] == 0x1b {
63 i = skipEscapeSequence(s, i)
64 continue
65 }
66 r, size := utf8.DecodeRuneInString(s[i:])
67 i += size
68 switch {
69 case r == '\t':
70 n := tabStop - col%tabStop
71 b.WriteString(strings.Repeat(" ", n))
72 col += n
73 case r == '\n' || r == '\r':
74 b.WriteByte(' ')
75 col++
76 case r == utf8.RuneError && size == 1, r < 0x20, r >= 0x7f && r < 0xa0:
77 // Invalid byte, C0/C1 control, or DEL: not printable.
78 case unicode.Is(unicode.Cf, r):
79 // Invisible format characters (bidi marks, joiners, prepended
80 // marks). Terminals disagree on how they join neighbouring
81 // cells, so their drawn width cannot be predicted.
82 default:
83 b.WriteRune(r)
84 col += runewidth.RuneWidth(r)
85 }
86 }
87 return b.String()
88}
89
90// isPlainText reports whether s is printable ASCII (plus LF when
91// allowNewline), the common case that needs no rewriting.
92func isPlainText(s string, allowNewline bool) bool {
93 for i := 0; i < len(s); i++ {
94 c := s[i]
95 if c == '\n' && allowNewline {
96 continue
97 }
98 if c < 0x20 || c >= 0x7f {
99 return false
100 }
101 }
102 return true
103}
104
105// skipEscapeSequence returns the index just past the escape sequence
106// starting at s[i].
107func skipEscapeSequence(s string, i int) int {
108 if i >= len(s) || s[i] != 0x1b {
109 return i + 1
110 }
111 if i+1 >= len(s) {
112 return len(s)
113 }
114 switch s[i+1] {
115 case '[': // CSI: ESC [ ... final byte 0x40-0x7e
116 j := i + 2
117 for j < len(s) {
118 c := s[j]
119 j++
120 if c >= 0x40 && c <= 0x7e {
121 break
122 }
123 }
124 return j
125 case ']': // OSC: ESC ] ... BEL or ST
126 return skipStringEscape(s, i+2)
127 case 'P', '_', '^', 'X': // DCS/APC/PM/SOS: ESC P ... ST, etc.
128 return skipStringEscape(s, i+2)
129 default:
130 // Two-byte escape (cursor save/restore, charset select, etc.).
131 return i + 2
132 }
133}
134
135func skipStringEscape(s string, i int) int {
136 for i < len(s) {
137 if s[i] == 0x07 { // BEL
138 return i + 1
139 }
140 if s[i] == 0x1b && i+1 < len(s) && s[i+1] == '\\' { // ST
141 return i + 2
142 }
143 i++
144 }
145 return len(s)
146}