styled.go

  1package uv
  2
  3import (
  4	"bytes"
  5	"image/color"
  6	"strings"
  7
  8	"github.com/charmbracelet/x/ansi"
  9)
 10
 11// StyledString is a string that can be decomposed into a series of styled
 12// lines and cells. It is used to disassemble a rendered string with ANSI
 13// escape codes into a series of cells that can be used in a [Buffer].
 14// A StyledString supports reading [ansi.SGR] and [ansi.Hyperlink] escape
 15// codes.
 16type StyledString struct {
 17	// Text is the original string that was used to create the styled string.
 18	Text string
 19	// Wrap determines whether the styled string should wrap to the next line.
 20	Wrap bool
 21	// Tail is the string that will be appended to the end of the line when the
 22	// string is truncated i.e. when [StyledString.Wrap] is false.
 23	Tail string
 24}
 25
 26var _ Drawable = (*StyledString)(nil)
 27
 28// NewStyledString creates a new [StyledString] for the given method and styled
 29// string. The method is used to calculate the width of each line.
 30func NewStyledString(str string) *StyledString {
 31	ss := new(StyledString)
 32	ss.Text = str
 33	return ss
 34}
 35
 36// Draw renders the styled string to the given buffer at the
 37// specified area.
 38func (s *StyledString) Draw(buf Screen, area Rectangle) {
 39	// Clear the area before drawing.
 40	for y := area.Min.Y; y < area.Max.Y; y++ {
 41		for x := area.Min.X; x < area.Max.X; x++ {
 42			buf.SetCell(x, y, nil)
 43		}
 44	}
 45	str := s.Text
 46	// We need to normalize newlines "\n" to "\r\n" to emulate a raw terminal
 47	// output.
 48	str = strings.ReplaceAll(str, "\r\n", "\n")
 49	str = strings.ReplaceAll(str, "\n", "\r\n")
 50	printString(buf, ansi.GraphemeWidth, area.Min.X, area.Min.Y, area, str, !s.Wrap, s.Tail)
 51}
 52
 53// Height returns the number of lines in the styled string. This is the number
 54// of lines that the styled string will occupy when rendered to the screen.
 55func (s *StyledString) Height() int {
 56	return strings.Count(s.Text, "\n") + 1
 57}
 58
 59// UnicodeWidth returns the cells width of the widest line in the styled string
 60// using the [ansi.GraphemeWidth] method.
 61func (s *StyledString) UnicodeWidth() int {
 62	w, _ := s.widthHeight(ansi.GraphemeWidth)
 63	return w
 64}
 65
 66// WcWidth returns the cells width of the widest line in the styled string
 67// using the [ansi.WcWidth] method.
 68func (s *StyledString) WcWidth() int {
 69	w, _ := s.widthHeight(ansi.WcWidth)
 70	return w
 71}
 72
 73func (s *StyledString) widthHeight(m ansi.Method) (w, h int) {
 74	lines := strings.Split(s.Text, "\n")
 75	h = len(lines)
 76	for _, l := range lines {
 77		w = max(w, m.StringWidth(l))
 78	}
 79	return
 80}
 81
 82// Bounds returns the minimum area that can contain the whole styled string.
 83func (s *StyledString) Bounds() Rectangle {
 84	w, h := s.widthHeight(ansi.GraphemeWidth)
 85	return Rect(0, 0, w, h)
 86}
 87
 88// printString draws a string starting at the given position.
 89func printString[T []byte | string](
 90	s Screen,
 91	m ansi.Method,
 92	x, y int,
 93	bounds Rectangle, str T,
 94	truncate bool, tail string,
 95) {
 96	p := ansi.NewParser()
 97
 98	var tailc Cell
 99	if truncate && len(tail) > 0 {
100		tailc = *NewCell(m, tail)
101	}
102
103	decoder := ansi.DecodeSequenceWc[T]
104	if m == ansi.GraphemeWidth {
105		decoder = ansi.DecodeSequence[T]
106	}
107
108	var cell Cell
109	var style Style
110	var link Link
111	var state byte
112	for len(str) > 0 {
113		seq, width, n, newState := decoder(str, state, p)
114		switch width {
115		case 1, 2, 3, 4: // wide cells can go up to 4 cells wide
116			cell.Width = width
117			cell.Content = string(seq)
118
119			if !truncate && x+cell.Width > bounds.Max.X && y+1 < bounds.Max.Y {
120				// Wrap the string to the width of the window
121				x = bounds.Min.X
122				y++
123			}
124
125			pos := Pos(x, y)
126			if pos.In(bounds) {
127				if truncate && tailc.Width > 0 && x+cell.Width > bounds.Max.X-tailc.Width {
128					// Truncate the string and append the tail if any.
129					cell = tailc
130					cell.Style = style
131					cell.Link = link
132					s.SetCell(x, y, &cell)
133					x += tailc.Width
134				} else {
135					// Print the cell to the screen
136					cell.Style = style
137					cell.Link = link
138					s.SetCell(x, y, &cell) //nolint:errcheck
139					x += width
140				}
141
142				// String is too long for the line, truncate it.
143				// Make sure we reset the cell for the next iteration.
144				cell = Cell{}
145			}
146		default:
147			// Valid sequences always have a non-zero Cmd.
148			// TODO: Handle cursor movement and other sequences
149			switch {
150			case ansi.HasCsiPrefix(seq) && p.Command() == 'm':
151				// SGR - Select Graphic Rendition
152				ReadStyle(p.Params(), &style)
153			case ansi.HasOscPrefix(seq) && p.Command() == 8:
154				// Hyperlinks
155				ReadLink(p.Data(), &link)
156			case ansi.Equal(seq, T("\n")):
157				y++
158			case ansi.Equal(seq, T("\r")):
159				x = bounds.Min.X
160			default:
161				cell.Content += string(seq)
162			}
163		}
164
165		// Advance the state and data
166		state = newState
167		str = str[n:]
168	}
169
170	// Make sure to set the last cell if it's not empty.
171	if !cell.IsZero() {
172		s.SetCell(x, y, &cell) //nolint:errcheck
173		cell = Cell{}
174	}
175}
176
177// ReadStyle reads a Select Graphic Rendition (SGR) escape sequences from a
178// list of parameters into pen.
179func ReadStyle(params ansi.Params, pen *Style) {
180	if len(params) == 0 {
181		*pen = Style{}
182		return
183	}
184
185	for i := 0; i < len(params); i++ {
186		param, hasMore, _ := params.Param(i, 0)
187		switch param {
188		case 0: // Reset
189			*pen = Style{}
190		case 1: // Bold
191			*pen = pen.Bold(true)
192		case 2: // Dim/Faint
193			*pen = pen.Faint(true)
194		case 3: // Italic
195			*pen = pen.Italic(true)
196		case 4: // Underline
197			nextParam, _, ok := params.Param(i+1, 0)
198			if hasMore && ok { // Only accept subparameters i.e. separated by ":"
199				switch nextParam {
200				case 0, 1, 2, 3, 4, 5:
201					i++
202					switch nextParam {
203					case 0: // No Underline
204						*pen = pen.UnderlineStyle(NoUnderline)
205					case 1: // Single Underline
206						*pen = pen.UnderlineStyle(SingleUnderline)
207					case 2: // Double Underline
208						*pen = pen.UnderlineStyle(DoubleUnderline)
209					case 3: // Curly Underline
210						*pen = pen.UnderlineStyle(CurlyUnderline)
211					case 4: // Dotted Underline
212						*pen = pen.UnderlineStyle(DottedUnderline)
213					case 5: // Dashed Underline
214						*pen = pen.UnderlineStyle(DashedUnderline)
215					}
216				}
217			} else {
218				// Single Underline
219				*pen = pen.UnderlineStyle(SingleUnderline)
220			}
221		case 5: // Slow Blink
222			*pen = pen.SlowBlink(true)
223		case 6: // Rapid Blink
224			*pen = pen.RapidBlink(true)
225		case 7: // Reverse
226			*pen = pen.Reverse(true)
227		case 8: // Conceal
228			*pen = pen.Conceal(true)
229		case 9: // Crossed-out/Strikethrough
230			*pen = pen.Strikethrough(true)
231		case 22: // Normal Intensity (not bold or faint)
232			*pen = pen.Bold(false).Faint(false)
233		case 23: // Not italic, not Fraktur
234			*pen = pen.Italic(false)
235		case 24: // Not underlined
236			*pen = pen.UnderlineStyle(NoUnderline)
237		case 25: // Blink off
238			*pen = pen.SlowBlink(false).RapidBlink(false)
239		case 27: // Positive (not reverse)
240			*pen = pen.Reverse(false)
241		case 28: // Reveal
242			*pen = pen.Conceal(false)
243		case 29: // Not crossed out
244			*pen = pen.Strikethrough(false)
245		case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground
246			*pen = pen.Foreground(ansi.Black + ansi.BasicColor(param-30)) //nolint:gosec
247		case 38: // Set foreground 256 or truecolor
248			var c color.Color
249			n := ansi.ReadStyleColor(params[i:], &c)
250			if n > 0 {
251				*pen = pen.Foreground(c)
252				i += n - 1
253			}
254		case 39: // Default foreground
255			*pen = pen.Foreground(nil)
256		case 40, 41, 42, 43, 44, 45, 46, 47: // Set background
257			*pen = pen.Background(ansi.Black + ansi.BasicColor(param-40)) //nolint:gosec
258		case 48: // Set background 256 or truecolor
259			var c color.Color
260			n := ansi.ReadStyleColor(params[i:], &c)
261			if n > 0 {
262				*pen = pen.Background(c)
263				i += n - 1
264			}
265		case 49: // Default Background
266			*pen = pen.Background(nil)
267		case 58: // Set underline color
268			var c color.Color
269			n := ansi.ReadStyleColor(params[i:], &c)
270			if n > 0 {
271				*pen = pen.Underline(c)
272				i += n - 1
273			}
274		case 59: // Default underline color
275			*pen = pen.Underline(nil)
276		case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground
277			*pen = pen.Foreground(ansi.BrightBlack + ansi.BasicColor(param-90)) //nolint:gosec
278		case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background
279			*pen = pen.Background(ansi.BrightBlack + ansi.BasicColor(param-100)) //nolint:gosec
280		}
281	}
282}
283
284// ReadLink reads a hyperlink escape sequence from a data buffer into link.
285func ReadLink(p []byte, link *Link) {
286	params := bytes.Split(p, []byte{';'})
287	if len(params) != 3 {
288		return
289	}
290	link.Params = string(params[1])
291	link.URL = string(params[2])
292}