wrap.go

  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}