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
 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}