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