1package cellbuf
2
3import (
4 "bytes"
5 "unicode"
6 "unicode/utf8"
7
8 "github.com/charmbracelet/x/ansi"
9)
10
11const nbsp = '\u00a0'
12
13// Wrap returns a string that is wrapped to the specified limit applying any
14// ANSI escape sequences in the string. It tries to wrap the string at word
15// boundaries, but will break words if necessary.
16//
17// The breakpoints string is a list of characters that are considered
18// breakpoints for word wrapping. A hyphen (-) is always considered a
19// breakpoint.
20//
21// Note: breakpoints must be a string of 1-cell wide rune characters.
22func Wrap(s string, limit int, breakpoints string) string {
23 // TODO: Use [PenWriter] once we get
24 // https://github.com/charmbracelet/lipgloss/pull/489 out the door and
25 // released.
26 // The problem is that [ansi.Wrap] doesn't keep track of style and link
27 // state, so combining both breaks styled space cells. To fix this, we use
28 // non-breaking space cells for padding and styled blank cells. And since
29 // both wrapping methods respect non-breaking spaces, we can use them to
30 // preserve styled spaces in the output.
31
32 if len(s) == 0 {
33 return ""
34 }
35
36 if limit < 1 {
37 return s
38 }
39
40 p := ansi.GetParser()
41 defer ansi.PutParser(p)
42
43 var (
44 buf bytes.Buffer
45 word bytes.Buffer
46 space bytes.Buffer
47 style, curStyle Style
48 link, curLink Link
49 curWidth int
50 wordLen int
51 )
52
53 hasBlankStyle := func() bool {
54 // Only follow reverse attribute, bg color and underline style
55 return !style.Attrs.Contains(ReverseAttr) && style.Bg == nil && style.UlStyle == NoUnderline
56 }
57
58 addSpace := func() {
59 curWidth += space.Len()
60 buf.Write(space.Bytes())
61 space.Reset()
62 }
63
64 addWord := func() {
65 if word.Len() == 0 {
66 return
67 }
68
69 curLink = link
70 curStyle = style
71
72 addSpace()
73 curWidth += wordLen
74 buf.Write(word.Bytes())
75 word.Reset()
76 wordLen = 0
77 }
78
79 addNewline := func() {
80 if !curStyle.Empty() {
81 buf.WriteString(ansi.ResetStyle)
82 }
83 if !curLink.Empty() {
84 buf.WriteString(ansi.ResetHyperlink())
85 }
86 buf.WriteByte('\n')
87 if !curLink.Empty() {
88 buf.WriteString(ansi.SetHyperlink(curLink.URL, curLink.Params))
89 }
90 if !curStyle.Empty() {
91 buf.WriteString(curStyle.Sequence())
92 }
93 curWidth = 0
94 space.Reset()
95 }
96
97 var state byte
98 for len(s) > 0 {
99 seq, width, n, newState := ansi.DecodeSequence(s, state, p)
100 switch width {
101 case 0:
102 if ansi.Equal(seq, "\t") {
103 addWord()
104 space.WriteString(seq)
105 break
106 } else if ansi.Equal(seq, "\n") {
107 if wordLen == 0 {
108 if curWidth+space.Len() > limit {
109 curWidth = 0
110 } else {
111 // preserve whitespaces
112 buf.Write(space.Bytes())
113 }
114 space.Reset()
115 }
116
117 addWord()
118 addNewline()
119 break
120 } else if ansi.HasCsiPrefix(seq) && p.Command() == 'm' {
121 // SGR style sequence [ansi.SGR]
122 ReadStyle(p.Params(), &style)
123 } else if ansi.HasOscPrefix(seq) && p.Command() == 8 {
124 // Hyperlink sequence [ansi.SetHyperlink]
125 ReadLink(p.Data(), &link)
126 }
127
128 word.WriteString(seq)
129 default:
130 if len(seq) == 1 {
131 // ASCII
132 r, _ := utf8.DecodeRuneInString(seq)
133 if r != nbsp && unicode.IsSpace(r) && hasBlankStyle() {
134 addWord()
135 space.WriteRune(r)
136 break
137 } else if r == '-' || runeContainsAny(r, breakpoints) {
138 addSpace()
139 if curWidth+wordLen+width <= limit {
140 addWord()
141 buf.WriteString(seq)
142 curWidth += width
143 break
144 }
145 }
146 }
147
148 if wordLen+width > limit {
149 // Hardwrap the word if it's too long
150 addWord()
151 }
152
153 word.WriteString(seq)
154 wordLen += width
155
156 if curWidth+wordLen+space.Len() > limit {
157 addNewline()
158 }
159 }
160
161 s = s[n:]
162 state = newState
163 }
164
165 if wordLen == 0 {
166 if curWidth+space.Len() > limit {
167 curWidth = 0
168 } else {
169 // preserve whitespaces
170 buf.Write(space.Bytes())
171 }
172 space.Reset()
173 }
174
175 addWord()
176
177 if !curLink.Empty() {
178 buf.WriteString(ansi.ResetHyperlink())
179 }
180 if !curStyle.Empty() {
181 buf.WriteString(ansi.ResetStyle)
182 }
183
184 return buf.String()
185}
186
187func runeContainsAny[T string | []rune](r rune, s T) bool {
188 for _, c := range []rune(s) {
189 if c == r {
190 return true
191 }
192 }
193 return false
194}