1package text
2
3import (
4 "strings"
5
6 "github.com/mattn/go-runewidth"
7)
8
9// Force runewidth not to treat ambiguous runes as wide chars, so that things
10// like unicode ellipsis/up/down/left/right glyphs can have correct runewidth
11// and can be displayed correctly in terminals.
12func init() {
13 runewidth.DefaultCondition.EastAsianWidth = false
14}
15
16// Wrap a text for a given line size.
17// Handle properly terminal color escape code
18func Wrap(text string, lineWidth int) (string, int) {
19 return WrapLeftPadded(text, lineWidth, 0)
20}
21
22// WrapLeftPadded wrap a text for a given line size with a left padding.
23// Handle properly terminal color escape code
24func WrapLeftPadded(text string, lineWidth int, leftPad int) (string, int) {
25 pad := strings.Repeat(" ", leftPad)
26 return WrapWithPad(text, lineWidth, pad)
27}
28
29// WrapWithPad wrap a text for a given line size with a custom left padding
30// Handle properly terminal color escape code
31func WrapWithPad(text string, lineWidth int, pad string) (string, int) {
32 return WrapWithPadIndent(text, lineWidth, pad, pad)
33}
34
35// WrapWithPad wrap a text for a given line size with a custom left padding
36// This function also align the result depending on the requested alignment.
37// Handle properly terminal color escape code
38func WrapWithPadAlign(text string, lineWidth int, pad string, align Alignment) (string, int) {
39 return WrapWithPadIndentAlign(text, lineWidth, pad, pad, align)
40}
41
42// WrapWithPadIndent wrap a text for a given line size with a custom left padding
43// and a first line indent. The padding is not effective on the first line, indent
44// is used instead, which allow to implement indents and outdents.
45// Handle properly terminal color escape code
46func WrapWithPadIndent(text string, lineWidth int, indent string, pad string) (string, int) {
47 return WrapWithPadIndentAlign(text, lineWidth, indent, pad, NoAlign)
48}
49
50// WrapWithPadIndentAlign wrap a text for a given line size with a custom left padding
51// and a first line indent. The padding is not effective on the first line, indent
52// is used instead, which allow to implement indents and outdents.
53// This function also align the result depending on the requested alignment.
54// Handle properly terminal color escape code
55func WrapWithPadIndentAlign(text string, lineWidth int, indent string, pad string, align Alignment) (string, int) {
56 var lines []string
57 nbLine := 0
58
59 // Start with the indent
60 padStr := indent
61 padLen := Len(indent)
62
63 // tabs are formatted as 4 spaces
64 text = strings.Replace(text, "\t", " ", -1)
65
66 // NOTE: text is first segmented into lines so that softwrapLine can handle.
67 for i, line := range strings.Split(text, "\n") {
68 // on the second line, use the padding instead
69 if i == 1 {
70 padStr = pad
71 padLen = Len(pad)
72 }
73
74 if line == "" || strings.TrimSpace(line) == "" {
75 // nothing in the line, we just add the non-empty part of the padding
76 lines = append(lines, strings.TrimRight(padStr, " "))
77 nbLine++
78 continue
79 }
80
81 wrapped := softwrapLine(line, lineWidth-padLen)
82 split := strings.Split(wrapped, "\n")
83
84 if i == 0 && len(split) > 1 {
85 // the very first line got wrapped
86 // that means we need to switch to the normal padding
87 // use the first wrapped line, ignore everything else and
88 // wrap the remaining of the line with the normal padding.
89
90 content := LineAlign(strings.TrimRight(split[0], " "), lineWidth-padLen, align)
91 lines = append(lines, padStr+content)
92 nbLine++
93 line = strings.TrimPrefix(line, split[0])
94 line = strings.TrimLeft(line, " ")
95
96 padStr = pad
97 padLen = Len(pad)
98 wrapped = softwrapLine(line, lineWidth-padLen)
99 split = strings.Split(wrapped, "\n")
100 }
101
102 for j, seg := range split {
103 if j == 0 {
104 // keep the left padding of the wrapped line
105 content := LineAlign(strings.TrimRight(seg, " "), lineWidth-padLen, align)
106 lines = append(lines, padStr+content)
107 } else {
108 content := LineAlign(strings.TrimSpace(seg), lineWidth-padLen, align)
109 lines = append(lines, padStr+content)
110 }
111 nbLine++
112 }
113 }
114
115 return strings.Join(lines, "\n"), nbLine
116}
117
118// Break a line into several lines so that each line consumes at most
119// 'textWidth' cells. Lines break at groups of white spaces and multibyte
120// chars. Nothing is removed from the original text so that it behaves like a
121// softwrap.
122//
123// Required: The line shall not contain '\n'
124//
125// WRAPPING ALGORITHM: The line is broken into non-breakable chunks, then line
126// breaks ("\n") are inserted between these groups so that the total length
127// between breaks does not exceed the required width. Words that are longer than
128// the textWidth are broken into pieces no longer than textWidth.
129func softwrapLine(line string, textWidth int) string {
130 escaped, escapes := ExtractTermEscapes(line)
131
132 chunks := segmentLine(escaped)
133 // Reverse the chunk array so we can use it as a stack.
134 for i, j := 0, len(chunks)-1; i < j; i, j = i+1, j-1 {
135 chunks[i], chunks[j] = chunks[j], chunks[i]
136 }
137
138 // for readability, minimal implementation of a stack:
139
140 pop := func() string {
141 result := chunks[len(chunks)-1]
142 chunks = chunks[:len(chunks)-1]
143 return result
144 }
145
146 push := func(chunk string) {
147 chunks = append(chunks, chunk)
148 }
149
150 peek := func() string {
151 return chunks[len(chunks)-1]
152 }
153
154 empty := func() bool {
155 return len(chunks) == 0
156 }
157
158 var out strings.Builder
159
160 // helper to write in the output while interleaving the escape
161 // sequence at the correct places.
162 // note: the final algorithm will add additional line break in the original
163 // text. Those line break are *not* fed to this helper so the positions don't
164 // need to be offset, which make the whole thing much easier.
165 currPos := 0
166 currItem := 0
167 outputString := func(s string) {
168 for _, r := range s {
169 for currItem < len(escapes) && currPos == escapes[currItem].Pos {
170 out.WriteString(escapes[currItem].Item)
171 currItem++
172 }
173 out.WriteRune(r)
174 currPos++
175 }
176 }
177
178 width := 0
179
180 for !empty() {
181 wl := Len(peek())
182
183 if width+wl <= textWidth {
184 // the chunk fit in the available space
185 outputString(pop())
186 width += wl
187 if width == textWidth && !empty() {
188 // only add line break when there is more chunk to come
189 out.WriteRune('\n')
190 width = 0
191 }
192 } else if wl > textWidth {
193 // words too long for a full line are split to fill the remaining space.
194 // But if the long words is the first non-space word in the middle of the
195 // line, preceding spaces shall not be counted in word splitting.
196 splitWidth := textWidth - width
197 if strings.HasSuffix(out.String(), "\n"+strings.Repeat(" ", width)) {
198 splitWidth += width
199 }
200 left, right := splitWord(pop(), splitWidth)
201 // remainder is pushed back to the stack for next round
202 push(right)
203 outputString(left)
204 out.WriteRune('\n')
205 width = 0
206 } else {
207 // normal line overflow, we add a line break and try again
208 out.WriteRune('\n')
209 width = 0
210 }
211 }
212
213 // Don't forget the trailing escapes, if any.
214 for currItem < len(escapes) && currPos >= escapes[currItem].Pos {
215 out.WriteString(escapes[currItem].Item)
216 currItem++
217 }
218
219 return out.String()
220}
221
222// Segment a line into chunks, where each chunk consists of chars with the same
223// type and is not breakable.
224func segmentLine(s string) []string {
225 var chunks []string
226
227 var word string
228 wordType := none
229 flushWord := func() {
230 chunks = append(chunks, word)
231 word = ""
232 wordType = none
233 }
234
235 for _, r := range s {
236 // A WIDE_CHAR itself constitutes a chunk.
237 thisType := runeType(r)
238 if thisType == wideChar {
239 if wordType != none {
240 flushWord()
241 }
242 chunks = append(chunks, string(r))
243 continue
244 }
245 // Other type of chunks starts with a char of that type, and ends with a
246 // char with different type or end of string.
247 if thisType != wordType {
248 if wordType != none {
249 flushWord()
250 }
251 word = string(r)
252 wordType = thisType
253 } else {
254 word += string(r)
255 }
256 }
257 if word != "" {
258 flushWord()
259 }
260
261 return chunks
262}
263
264type RuneType int
265
266// Rune categories
267//
268// These categories are so defined that each category forms a non-breakable
269// chunk. It IS NOT the same as unicode code point categories.
270const (
271 none RuneType = iota
272 wideChar
273 invisible
274 shortUnicode
275 space
276 visibleAscii
277)
278
279// Determine the category of a rune.
280func runeType(r rune) RuneType {
281 rw := runewidth.RuneWidth(r)
282 if rw > 1 {
283 return wideChar
284 } else if rw == 0 {
285 return invisible
286 } else if r > 127 {
287 return shortUnicode
288 } else if r == ' ' {
289 return space
290 } else {
291 return visibleAscii
292 }
293}
294
295// splitWord split a word at the given length, while ignoring the terminal escape sequences
296func splitWord(word string, length int) (string, string) {
297 runes := []rune(word)
298 var result []rune
299 added := 0
300 escape := false
301
302 if length == 0 {
303 return "", word
304 }
305
306 for _, r := range runes {
307 if r == '\x1b' {
308 escape = true
309 }
310
311 width := runewidth.RuneWidth(r)
312 if width+added > length {
313 // wide character made the length overflow
314 break
315 }
316
317 result = append(result, r)
318
319 if !escape {
320 added += width
321 if added >= length {
322 break
323 }
324 }
325
326 if r == 'm' {
327 escape = false
328 }
329 }
330
331 leftover := runes[len(result):]
332
333 return string(result), string(leftover)
334}