text.go

  1package text
  2
  3import (
  4	"github.com/mattn/go-runewidth"
  5	"strings"
  6	"unicode/utf8"
  7)
  8
  9// Wrap a text for an exact line size
 10// Handle properly terminal color escape code
 11func Wrap(text string, lineWidth int) (string, int) {
 12	return WrapLeftPadded(text, lineWidth, 0)
 13}
 14
 15// Wrap a text for an exact line size with a left padding
 16// Handle properly terminal color escape code
 17func WrapLeftPadded(text string, lineWidth int, leftPad int) (string, int) {
 18	pad := strings.Repeat(" ", leftPad)
 19	var lines []string
 20	nbLine := 0
 21
 22	// tabs are formatted as 4 spaces
 23	text = strings.Replace(text, "\t", "    ", -1)
 24	for _, line := range strings.Split(text, "\n") {
 25		if line == "" || strings.TrimSpace(line) == "" {
 26			lines = append(lines, "")
 27			nbLine++
 28		} else {
 29			wrapped := softwrapLine(line, lineWidth-leftPad)
 30			firstLine := true
 31			for _, seg := range strings.Split(wrapped, "\n") {
 32				if firstLine {
 33					lines = append(lines, pad+strings.TrimRight(seg, " "))
 34					firstLine = false
 35				} else {
 36					lines = append(lines, pad+strings.TrimSpace(seg))
 37				}
 38				nbLine++
 39			}
 40		}
 41	}
 42	return strings.Join(lines, "\n"), nbLine
 43}
 44
 45type EscapeItem struct {
 46	item string
 47	pos  int
 48}
 49
 50func recordTermEscape(s string) (string, []EscapeItem) {
 51	var result []EscapeItem
 52	var newStr string
 53
 54	pos := 0
 55	item := ""
 56	occupiedRuneCount := 0
 57	inEscape := false
 58	for i, r := range []rune(s) {
 59		if r == '\x1b' {
 60			pos = i
 61			item = string(r)
 62			inEscape = true
 63			continue
 64		}
 65		if inEscape {
 66			item += string(r)
 67			if r == 'm' {
 68				result = append(result, EscapeItem{item: item, pos: pos - occupiedRuneCount})
 69				occupiedRuneCount += utf8.RuneCountInString(item)
 70				inEscape = false
 71			}
 72			continue
 73		}
 74		newStr += string(r)
 75	}
 76
 77	return newStr, result
 78}
 79
 80func replayTermEscape(s string, sequence []EscapeItem) string {
 81	if len(sequence) == 0 {
 82		return string(s)
 83	}
 84	// Assume the original string contains no new line and the wrapped only insert
 85	// new lines. So that we can recover the position where we insert the term
 86	// escapes.
 87	var out string = ""
 88
 89	currPos := 0
 90	currItem := 0
 91	for _, r := range []rune(s) {
 92		if currItem < len(sequence) && currPos == sequence[currItem].pos {
 93			if r == '\n' {
 94				out += "\n" + sequence[currItem].item
 95			} else {
 96				out += sequence[currItem].item + string(r)
 97				currPos++
 98			}
 99			currItem++
100		} else {
101			if r != '\n' {
102				currPos++
103			}
104			out += string(r)
105		}
106	}
107
108	return out
109}
110
111// Break a line into several lines so that each line consumes at most 'w' cells.
112// Lines break at group of white spaces and multibyte chars. Nothing is removed
113// from the line so that it behaves like a softwrap.
114//
115// Required: The line shall not contain '\n' (so it is a single line).
116//
117// WRAPPING ALGORITHM: The line is broken into non-breakable groups, then line
118// breaks ("\n") is inserted between these groups so that the total length
119// between breaks does not exceed the required width. Words that are longer than
120// the width is broken into several words as `M+M+...+N`.
121func softwrapLine(s string, w int) string {
122	newStr, termSeqs := recordTermEscape(s)
123
124	const (
125		WIDE_CHAR     = iota
126		INVISIBLE     = iota
127		SHORT_UNICODE = iota
128		SPACE         = iota
129		VISIBLE_ASCII = iota
130		NONE          = iota
131	)
132
133	// In order to simplify the terminal color sequence handling, we first strip
134	// them out of the text and record their position, then do the wrap. After
135	// that, we insert back these sequences.
136	runeType := func(r rune) int {
137		rw := runewidth.RuneWidth(r)
138		if rw > 1 {
139			return WIDE_CHAR
140		} else if rw == 0 {
141			return INVISIBLE
142		} else if r > 127 {
143			return SHORT_UNICODE
144		} else if r == ' ' {
145			return SPACE
146		} else {
147			return VISIBLE_ASCII
148		}
149	}
150
151	var chunks []string
152	var word string
153	wordType := NONE
154	flushWord := func() {
155		chunks = append(chunks, word)
156		word = ""
157		wordType = NONE
158	}
159	for _, r := range []rune(newStr) {
160		// A WIDE_CHAR itself constitutes a group.
161		thisType := runeType(r)
162		if thisType == WIDE_CHAR {
163			if wordType != NONE {
164				flushWord()
165			}
166			chunks = append(chunks, string(r))
167			continue
168		}
169		// Other type of groups starts with a char of that type, and ends with a
170		// char with different type or end of string.
171		if thisType != wordType {
172			if wordType != NONE {
173				flushWord()
174			}
175			word = string(r)
176			wordType = thisType
177		} else {
178			word += string(r)
179		}
180	}
181	if word != "" {
182		flushWord()
183	}
184
185	var line string = ""
186	var width int = 0
187	// Reverse the chunk array so we can use it as a stack.
188	for i, j := 0, len(chunks)-1; i < j; i, j = i+1, j-1 {
189		chunks[i], chunks[j] = chunks[j], chunks[i]
190	}
191	for len(chunks) > 0 {
192		thisWord := chunks[len(chunks)-1]
193		wl := wordLen(thisWord)
194		if width+wl <= w {
195			line += chunks[len(chunks)-1]
196			chunks = chunks[:len(chunks)-1]
197			width += wl
198			if width == w && len(chunks) > 0 {
199				line += "\n"
200				width = 0
201			}
202		} else if wl > w {
203			left, right := splitWord(chunks[len(chunks)-1], w)
204			line += left + "\n"
205			chunks[len(chunks)-1] = right
206			width = 0
207		} else {
208			line += "\n"
209			width = 0
210		}
211	}
212
213	line = replayTermEscape(line, termSeqs)
214	return line
215}
216
217// wordLen return the length of a word, while ignoring the terminal escape
218// sequences
219func wordLen(word string) int {
220	length := 0
221	escape := false
222
223	for _, char := range word {
224		if char == '\x1b' {
225			escape = true
226		}
227		if !escape {
228			length += runewidth.RuneWidth(rune(char))
229		}
230		if char == 'm' {
231			escape = false
232		}
233	}
234
235	return length
236}
237
238// splitWord split a word at the given length, while ignoring the terminal escape sequences
239func splitWord(word string, length int) (string, string) {
240	runes := []rune(word)
241	var result []rune
242	added := 0
243	escape := false
244
245	if length == 0 {
246		return "", word
247	}
248
249	for _, r := range runes {
250		if r == '\x1b' {
251			escape = true
252		}
253
254		width := runewidth.RuneWidth(r)
255		if width+added > length {
256			// wide character made the length overflow
257			break
258		}
259
260		result = append(result, r)
261
262		if !escape {
263			added += width
264			if added >= length {
265				break
266			}
267		}
268
269		if r == 'm' {
270			escape = false
271		}
272	}
273
274	leftover := runes[len(result):]
275
276	return string(result), string(leftover)
277}
278
279func minInt(a, b int) int {
280	if a > b {
281		return b
282	}
283	return a
284}