wrap.go

  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}