okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

Stanislav (Stas) Katkovfix(styled): allow semicolons in hyperlink URLs (#172)ae99b73

upstream
14.5 KiB451 linesraw
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// String returns the text of the styled string.
37//
38// It implements the [fmt.Stringer] interface.
39func (s *StyledString) String() string {
40	return s.Text
41}
42
43// Lines returns the styled string decomposed into a slice of [Line]s.
44func (s *StyledString) Lines(m ansi.Method) []Line {
45	return printString(nil, m, 0, 0, Rectangle{}, s.Text, false, "")
46}
47
48// Draw renders the styled string to the given buffer at the
49// specified area.
50func (s *StyledString) Draw(buf Screen, area Rectangle) {
51	// Clear the area before drawing.
52	for y := area.Min.Y; y < area.Max.Y; y++ {
53		for x := area.Min.X; x < area.Max.X; x++ {
54			buf.SetCell(x, y, nil)
55		}
56	}
57	str := s.Text
58	// We need to normalize newlines "\n" to "\r\n" to emulate a raw terminal
59	// output.
60	str = strings.ReplaceAll(str, "\r\n", "\n")
61	printString(buf, buf.WidthMethod(), area.Min.X, area.Min.Y, area, str, !s.Wrap, s.Tail)
62}
63
64// Height returns the number of lines in the styled string. This is the number
65// of lines that the styled string will occupy when rendered to the screen.
66func (s *StyledString) Height() int {
67	return strings.Count(s.Text, "\n") + 1
68}
69
70// UnicodeWidth returns the cells width of the widest line in the styled string
71// using the [ansi.GraphemeWidth] method.
72func (s *StyledString) UnicodeWidth() int {
73	w, _ := s.widthHeight(ansi.GraphemeWidth)
74	return w
75}
76
77// WcWidth returns the cells width of the widest line in the styled string
78// using the [ansi.WcWidth] method.
79func (s *StyledString) WcWidth() int {
80	w, _ := s.widthHeight(ansi.WcWidth)
81	return w
82}
83
84func (s *StyledString) widthHeight(m ansi.Method) (w, h int) {
85	lines := strings.Split(s.Text, "\n")
86	h = len(lines)
87	for _, l := range lines {
88		w = max(w, m.StringWidth(l))
89	}
90	return
91}
92
93// Bounds returns the minimum area that can contain the whole styled string.
94func (s *StyledString) Bounds() Rectangle {
95	w, h := s.widthHeight(ansi.GraphemeWidth)
96	return Rect(0, 0, w, h)
97}
98
99// printString draws a string starting at the given position. If s is nil, it
100// will build and return a slice of [Line]s instead (unwrapped, ignoring bounds).
101// passThrough reports whether a zero-width sequence should be carried in a
102// cell's content and replayed to the terminal, rather than dropped.
103//
104// The string-type sequences carry private data rather than driving the cursor:
105// APC for image protocols, DCS for device control, SOS and PM for whatever an
106// application agrees with its terminal. One means it to reach the terminal, and
107// a cell carrying one still advances by its own width, so the renderer's column
108// model still holds.
109//
110// Everything else is dropped, as it was before:
111//
112//   - A CSI can move the cursor or erase part of the screen, and an ESC Fs
113//     sequence can reset the terminal outright. Replaying one from inside a
114//     cell would move the real cursor somewhere the model cannot see, which is
115//     the drift a cell buffer exists to keep out.
116//   - An OSC is dropped despite carrying data, because a cell is painted again
117//     every time it changes and on every full repaint. A window title survives
118//     that, but a clipboard write (OSC 52) or a notification (OSC 9) is not
119//     something to fire again on each resize. Hyperlinks, the one OSC a cell
120//     has a place for, are read into [Link] above.
121func passThrough[T []byte | string](seq T) bool {
122	if !ansi.HasApcPrefix(seq) && !ansi.HasDcsPrefix(seq) &&
123		!ansi.HasSosPrefix(seq) && !ansi.HasPmPrefix(seq) {
124		return false
125	}
126	return terminated(seq)
127}
128
129// terminated reports whether a string-type sequence ended with ST.
130//
131// The parser hands back whatever it has when the input runs out, so a string
132// that stops mid-sequence still arrives here. Carrying an unterminated
133// introducer into a cell would be worse than dropping it: the terminal would
134// swallow everything painted after that cell, looking for an end that never
135// comes.
136//
137// Only the two-byte ST is accepted. The one-byte C1 form, 0x9c, is also a
138// UTF-8 continuation byte, so a sequence that ran out partway through a
139// character such as U+071C ("\u071c", encoded dc 9c) ends in a byte
140// indistinguishable from a terminator. The parser and the terminal need not
141// resolve that ambiguity the same way, and guessing wrong reintroduces exactly
142// the swallowing this guards against.
143func terminated[T []byte | string](seq T) bool {
144	n := len(seq)
145	return n >= 2 && seq[n-1] == '\\' && seq[n-2] == ansi.ESC
146}
147
148func printString[T []byte | string](
149	s Screen,
150	m WidthMethod,
151	x, y int,
152	bounds Rectangle, str T,
153	truncate bool, tail string,
154) (lines []Line) {
155	p := ansi.GetParser()
156	defer ansi.PutParser(p)
157
158	var tailc Cell
159	if truncate && len(tail) > 0 {
160		tailc = *NewCell(m, tail)
161	}
162
163	// A [WidthMethod] the ansi package doesn't know about falls back to
164	// wcwidth, so the segmenter below always agrees with the decoder.
165	decoder, method := ansi.DecodeSequenceWc[T], ansi.WcWidth
166	if m == ansi.GraphemeWidth {
167		decoder, method = ansi.DecodeSequence[T], ansi.GraphemeWidth
168	}
169
170	if s == nil {
171		lines = []Line{}
172	}
173
174	var cell Cell
175	var style Style
176	var link Link
177	var state byte
178	lastX, lastY := -1, -1 // last cell written, for folding in combining marks
179	var pending []byte     // pass-through sequences awaiting a cell to ride on
180	for len(str) > 0 {
181		seq, width, n, newState := decoder(str, state, p)
182		// The decoder's ASCII fast path doesn't check for trailing combining
183		// sequences, so re-decode as a full cluster when one starts here.
184		// The str[1] >= 0xc0 check skips the segmenter for plain ASCII.
185		if n == 1 && seq[0] > 0x1f && seq[0] < 0x7f && len(str) > 1 && str[1] >= 0xc0 {
186			if cluster, cw := ansi.FirstGraphemeCluster(str, method); len(cluster) > 1 {
187				seq, width, n = cluster, cw, len(cluster)
188			}
189		}
190		switch {
191		// Any positive width is a printable grapheme cluster. Wcwidth measures
192		// a cluster per codepoint, so this is not bounded by how wide a glyph
193		// can be: a ZWJ emoji sequence such as "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" is eight columns,
194		// and capping the width here would fall through to the escape sequence
195		// branch and drop the cluster from the screen.
196		case width > 0:
197			cell.Width = width
198			cell.Content = string(seq)
199			// Any pass-through sequences seen since the last cell belong in
200			// front of this glyph, the order they arrived in. Checked rather
201			// than joined unconditionally, because there are none at all on the
202			// overwhelmingly common path and the join is not free.
203			if len(pending) > 0 {
204				pending = append(pending, cell.Content...)
205				cell.Content = string(pending)
206				pending = pending[:0]
207			}
208			cell.Style = style
209			cell.Link = link
210
211			if s == nil {
212				// Building lines: unwrapped, no bounds
213				if y >= len(lines) {
214					lines = append(lines, Line{})
215				}
216				lines[y] = append(lines[y], cell)
217				lastX, lastY = len(lines[y])-1, y
218				x += width
219			} else {
220				// Drawing to screen: handle wrapping, truncation, and bounds
221				if !truncate && x+cell.Width > bounds.Max.X && y+1 < bounds.Max.Y {
222					// Wrap the string to the width of the window
223					x = bounds.Min.X
224					y++
225				}
226
227				pos := Pos(x, y)
228				if pos.In(bounds) {
229					if truncate && tailc.Width > 0 && x+cell.Width > bounds.Max.X-tailc.Width {
230						// Truncate the string and append the tail if any.
231						cell = tailc
232						cell.Style = style
233						cell.Link = link
234						s.SetCell(x, y, &cell)
235						lastX, lastY = x, y
236						x += tailc.Width
237					} else {
238						// Print the cell to the screen
239						s.SetCell(x, y, &cell)
240						lastX, lastY = x, y
241						x += width
242					}
243				}
244			}
245
246			// Reset cell for next iteration
247			cell = Cell{}
248		default:
249			// Valid sequences always have a non-zero Cmd.
250			// TODO: Handle cursor movement and other sequences
251			switch {
252			case ansi.HasCsiPrefix(seq) && p.Command() == 'm':
253				// SGR - Select Graphic Rendition
254				ReadStyle(p.Params(), &style)
255			case ansi.HasOscPrefix(seq) && p.Command() == 8:
256				// Hyperlinks
257				ReadLink(p.Data(), &link)
258			case ansi.Equal(seq, T("\n")):
259				if s == nil {
260					// When building lines, we need to ensure empty lines are represented.
261					if y >= len(lines) {
262						lines = append(lines, Line{})
263					}
264				}
265				y++
266				// Always treat a NL as CR-LF similar to Termios ONLCR.
267				fallthrough
268			case ansi.Equal(seq, T("\r")):
269				if s == nil {
270					x = 0
271				} else {
272					x = bounds.Min.X
273				}
274			case len(seq) > 0 && seq[0] >= 0xc0:
275				// A zero-width grapheme, i.e. a combining mark the re-decode
276				// above couldn't fold because an escape sequence sits between
277				// it and its base, as in "a\x1b[31m\u0301". Every escape
278				// introducer is a C0 or C1 byte, so a UTF-8 lead byte here is
279				// always text. The mark belongs to the cell we last wrote; the
280				// accumulator below would let the next glyph clobber it.
281				if s == nil {
282					if lastY >= 0 && lastY < len(lines) && lastX < len(lines[lastY]) {
283						lines[lastY][lastX].Content += string(seq)
284					}
285				} else if lastX >= 0 {
286					if prev := s.CellAt(lastX, lastY); prev != nil {
287						folded := *prev
288						folded.Content += string(seq)
289						s.SetCell(lastX, lastY, &folded)
290					}
291				}
292			case passThrough(seq):
293				pending = append(pending, string(seq)...)
294			}
295		}
296
297		// Advance the state and data
298		state = newState
299		str = str[n:]
300
301		if s != nil && y >= bounds.Max.Y {
302			// We've reached the bottom of the bounds, stop processing further
303			// lines.
304			break
305		}
306	}
307
308	// Pass-through sequences left at the end of the string have no glyph to
309	// ride in front of, so fold them into the last cell written instead. The
310	// alternative is a width-0 cell one past the content, which lands outside
311	// the bounds whenever the string filled them.
312	if len(pending) > 0 {
313		if s == nil {
314			if lastY >= 0 && lastY < len(lines) && lastX >= 0 && lastX < len(lines[lastY]) {
315				lines[lastY][lastX].Content += string(pending)
316			}
317		} else if lastX >= 0 {
318			if prev := s.CellAt(lastX, lastY); prev != nil {
319				folded := *prev
320				folded.Content += string(pending)
321				s.SetCell(lastX, lastY, &folded)
322			}
323		}
324	}
325
326	// Make sure to set the last cell if it's not empty.
327	if !cell.IsZero() && s != nil {
328		s.SetCell(x, y, &cell)
329	}
330
331	return lines
332}
333
334// ReadStyle reads a Select Graphic Rendition (SGR) escape sequences from a
335// list of parameters into pen.
336func ReadStyle(params ansi.Params, pen *Style) {
337	if len(params) == 0 {
338		*pen = Style{}
339		return
340	}
341
342	for i := 0; i < len(params); i++ {
343		param, hasMore, _ := params.Param(i, 0)
344		switch param {
345		case 0: // Reset
346			*pen = Style{}
347		case 1: // Bold
348			pen.Attrs |= AttrBold
349		case 2: // Dim/Faint
350			pen.Attrs |= AttrFaint
351		case 3: // Italic
352			pen.Attrs |= AttrItalic
353		case 4: // Underline
354			nextParam, _, ok := params.Param(i+1, 0)
355			if hasMore && ok { // Only accept subparameters i.e. separated by ":"
356				switch nextParam {
357				case 0, 1, 2, 3, 4, 5:
358					i++
359					switch nextParam {
360					case 0: // No Underline
361						pen.Underline = UnderlineStyleNone
362					case 1: // Single Underline
363						pen.Underline = UnderlineStyleSingle
364					case 2: // Double Underline
365						pen.Underline = UnderlineStyleDouble
366					case 3: // Curly Underline
367						pen.Underline = UnderlineStyleCurly
368					case 4: // Dotted Underline
369						pen.Underline = UnderlineStyleDotted
370					case 5: // Dashed Underline
371						pen.Underline = UnderlineStyleDashed
372					}
373				}
374			} else {
375				// Single Underline
376				pen.Underline = UnderlineStyleSingle
377			}
378		case 5: // Slow Blink
379			pen.Attrs |= AttrBlink
380		case 6: // Rapid Blink
381			pen.Attrs |= AttrRapidBlink
382		case 7: // Reverse
383			pen.Attrs |= AttrReverse
384		case 8: // Conceal
385			pen.Attrs |= AttrConceal
386		case 9: // Crossed-out/Strikethrough
387			pen.Attrs |= AttrStrikethrough
388		case 22: // Normal Intensity (not bold or faint)
389			pen.Attrs &^= (AttrBold | AttrFaint)
390		case 23: // Not italic, not Fraktur
391			pen.Attrs &^= AttrItalic
392		case 24: // Not underlined
393			pen.Underline = UnderlineStyleNone
394		case 25: // Blink off
395			pen.Attrs &^= (AttrBlink | AttrRapidBlink)
396		case 27: // Positive (not reverse)
397			pen.Attrs &^= AttrReverse
398		case 28: // Reveal
399			pen.Attrs &^= AttrConceal
400		case 29: // Not crossed out
401			pen.Attrs &^= AttrStrikethrough
402		case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground
403			pen.Fg = ansi.Black + ansi.BasicColor(param-30) //nolint:gosec
404		case 38: // Set foreground 256 or truecolor
405			var c color.Color
406			n := ansi.ReadStyleColor(params[i:], &c)
407			if n > 0 {
408				pen.Fg = c
409				i += n - 1
410			}
411		case 39: // Default foreground
412			pen.Fg = nil
413		case 40, 41, 42, 43, 44, 45, 46, 47: // Set background
414			pen.Bg = ansi.Black + ansi.BasicColor(param-40) //nolint:gosec
415		case 48: // Set background 256 or truecolor
416			var c color.Color
417			n := ansi.ReadStyleColor(params[i:], &c)
418			if n > 0 {
419				pen.Bg = c
420				i += n - 1
421			}
422		case 49: // Default Background
423			pen.Bg = nil
424		case 58: // Set underline color
425			var c color.Color
426			n := ansi.ReadStyleColor(params[i:], &c)
427			if n > 0 {
428				pen.UnderlineColor = c
429				i += n - 1
430			}
431		case 59: // Default underline color
432			pen.UnderlineColor = nil
433		case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground
434			pen.Fg = ansi.BrightBlack + ansi.BasicColor(param-90) //nolint:gosec
435		case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background
436			pen.Bg = ansi.BrightBlack + ansi.BasicColor(param-100) //nolint:gosec
437		}
438	}
439}
440
441// ReadLink reads a hyperlink escape sequence from a data buffer into link.
442func ReadLink(p []byte, link *Link) {
443	// OSC 8 sequences have this structure `OSC 8 ; params ; URI ST`.
444	// Only the first two semicolons are delimiters, semicolons that follow after are part of the URI.
445	params := bytes.SplitN(p, []byte{';'}, 3)
446	if len(params) != 3 {
447		return
448	}
449	link.Params = string(params[1])
450	link.URL = string(params[2])
451}