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