File: wrap.go

package info (click to toggle)
golang-github-charmbracelet-x 0.0~git20251028.0cf22f8%2Bds-3
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,940 kB
  • sloc: sh: 124; makefile: 5
file content (191 lines) | stat: -rw-r--r-- 4,146 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
package cellbuf

import (
	"bytes"
	"slices"
	"unicode"
	"unicode/utf8"

	"github.com/charmbracelet/x/ansi"
)

const nbsp = '\u00a0'

// Wrap returns a string that is wrapped to the specified limit applying any
// ANSI escape sequences in the string. It tries to wrap the string at word
// boundaries, but will break words if necessary.
//
// The breakpoints string is a list of characters that are considered
// breakpoints for word wrapping. A hyphen (-) is always considered a
// breakpoint.
//
// Note: breakpoints must be a string of 1-cell wide rune characters.
func Wrap(s string, limit int, breakpoints string) string {
	//nolint:godox
	// TODO: Use [PenWriter] once we get
	// https://github.com/charmbracelet/lipgloss/pull/489 out the door and
	// released.
	// The problem is that [ansi.Wrap] doesn't keep track of style and link
	// state, so combining both breaks styled space cells. To fix this, we use
	// non-breaking space cells for padding and styled blank cells. And since
	// both wrapping methods respect non-breaking spaces, we can use them to
	// preserve styled spaces in the output.

	if len(s) == 0 {
		return ""
	}

	if limit < 1 {
		return s
	}

	p := ansi.GetParser()
	defer ansi.PutParser(p)

	var (
		buf             bytes.Buffer
		word            bytes.Buffer
		space           bytes.Buffer
		style, curStyle Style
		link, curLink   Link
		curWidth        int
		wordLen         int
	)

	hasBlankStyle := func() bool {
		// Only follow reverse attribute, bg color and underline style
		return !style.Attrs.Contains(ReverseAttr) && style.Bg == nil && style.UlStyle == NoUnderline
	}

	addSpace := func() {
		curWidth += space.Len()
		buf.Write(space.Bytes())
		space.Reset()
	}

	addWord := func() {
		if word.Len() == 0 {
			return
		}

		curLink = link
		curStyle = style

		addSpace()
		curWidth += wordLen
		buf.Write(word.Bytes())
		word.Reset()
		wordLen = 0
	}

	addNewline := func() {
		if !curStyle.Empty() {
			buf.WriteString(ansi.ResetStyle)
		}
		if !curLink.Empty() {
			buf.WriteString(ansi.ResetHyperlink())
		}
		buf.WriteByte('\n')
		if !curLink.Empty() {
			buf.WriteString(ansi.SetHyperlink(curLink.URL, curLink.Params))
		}
		if !curStyle.Empty() {
			buf.WriteString(curStyle.Sequence())
		}
		curWidth = 0
		space.Reset()
	}

	var state byte
	for len(s) > 0 {
		seq, width, n, newState := ansi.DecodeSequence(s, state, p)
		switch width {
		case 0:
			if ansi.Equal(seq, "\t") { //nolint:nestif
				addWord()
				space.WriteString(seq)
				break
			} else if ansi.Equal(seq, "\n") {
				if wordLen == 0 {
					if curWidth+space.Len() > limit {
						curWidth = 0
					} else {
						// preserve whitespaces
						buf.Write(space.Bytes())
					}
					space.Reset()
				}

				addWord()
				addNewline()
				break
			} else if ansi.HasCsiPrefix(seq) && p.Command() == 'm' {
				// SGR style sequence [ansi.SGR]
				ReadStyle(p.Params(), &style)
			} else if ansi.HasOscPrefix(seq) && p.Command() == 8 {
				// Hyperlink sequence [ansi.SetHyperlink]
				ReadLink(p.Data(), &link)
			}

			word.WriteString(seq)
		default:
			if len(seq) == 1 {
				// ASCII
				r, _ := utf8.DecodeRuneInString(seq)
				if r != nbsp && unicode.IsSpace(r) && hasBlankStyle() {
					addWord()
					space.WriteRune(r)
					break
				} else if r == '-' || runeContainsAny(r, breakpoints) {
					addSpace()
					if curWidth+wordLen+width <= limit {
						addWord()
						buf.WriteString(seq)
						curWidth += width
						break
					}
				}
			}

			if wordLen+width > limit {
				// Hardwrap the word if it's too long
				addWord()
			}

			word.WriteString(seq)
			wordLen += width

			if curWidth+wordLen+space.Len() > limit {
				addNewline()
			}
		}

		s = s[n:]
		state = newState
	}

	if wordLen == 0 {
		if curWidth+space.Len() > limit {
			curWidth = 0
		} else {
			// preserve whitespaces
			buf.Write(space.Bytes())
		}
		space.Reset()
	}

	addWord()

	if !curLink.Empty() {
		buf.WriteString(ansi.ResetHyperlink())
	}
	if !curStyle.Empty() {
		buf.WriteString(ansi.ResetStyle)
	}

	return buf.String()
}

func runeContainsAny[T string | []rune](r rune, s T) bool {
	return slices.Contains([]rune(s), r)
}