okami.mom/ultraviolet
git clone https://git.nkp.pet/okami.mom/ultraviolet
ae99b73
upstream
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. 18Text string 19// Wrap determines whether the styled string should wrap to the next line. 20Wrap 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. 23Tail 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 { 31ss := new (StyledString ) 32ss .Text = str 33return 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 { 40return 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 { 45return 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. 52for y := area .Min .Y ;y < area .Max .Y ;y ++ { 53for x := area .Min .X ;x < area .Max .X ;x ++ { 54buf .SetCell (x ,y ,nil ) 55 } 56 } 57str := s .Text 58// We need to normalize newlines "\n" to "\r\n" to emulate a raw terminal 59// output. 60str = strings .ReplaceAll (str ,"\r\n" ,"\n" ) 61printString (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 { 67return 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 { 73w ,_ := s .widthHeight (ansi .GraphemeWidth ) 74return 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 { 80w ,_ := s .widthHeight (ansi .WcWidth ) 81return w 82} 83 84func (s * StyledString )widthHeight (m ansi.Method ) (w ,h int ) { 85lines := strings .Split (s .Text ,"\n" ) 86h = len (lines ) 87for _ ,l := range lines { 88w = max (w ,m .StringWidth (l )) 89 } 90return 91} 92 93// Bounds returns the minimum area that can contain the whole styled string. 94func (s * StyledString )Bounds ()Rectangle { 95w ,h := s .widthHeight (ansi .GraphemeWidth ) 96return 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 { 122if ! ansi .HasApcPrefix (seq )&& ! ansi .HasDcsPrefix (seq )&& 123! ansi .HasSosPrefix (seq )&& ! ansi .HasPmPrefix (seq ) { 124return false 125 } 126return 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 { 144n := len (seq ) 145return n >= 2 && seq [n - 1 ]== '\\' && seq [n - 2 ]== ansi .ESC 146} 147 148func printString [T []byte | string ]( 149s Screen , 150m WidthMethod , 151x ,y int , 152bounds Rectangle ,str T , 153truncate bool ,tail string , 154) (lines []Line ) { 155p := ansi .GetParser () 156defer ansi .PutParser (p ) 157 158var tailc Cell 159if truncate && len (tail )> 0 { 160tailc = * 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. 165decoder ,method := ansi .DecodeSequenceWc [T ],ansi .WcWidth 166if m == ansi .GraphemeWidth { 167decoder ,method = ansi .DecodeSequence [T ],ansi .GraphemeWidth 168 } 169 170if s == nil { 171lines = []Line {} 172 } 173 174var cell Cell 175var style Style 176var link Link 177var state byte 178lastX ,lastY := - 1 ,- 1 // last cell written, for folding in combining marks 179var pending []byte // pass-through sequences awaiting a cell to ride on 180for len (str )> 0 { 181seq ,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. 185if n == 1 && seq [0 ]> 0x1f && seq [0 ]< 0x7f && len (str )> 1 && str [1 ]>= 0xc0 { 186if cluster ,cw := ansi .FirstGraphemeCluster (str ,method );len (cluster )> 1 { 187seq ,width ,n = cluster ,cw ,len (cluster ) 188 } 189 } 190switch { 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. 196case width > 0 : 197cell .Width = width 198cell .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. 203if len (pending )> 0 { 204pending = append (pending ,cell .Content ... ) 205cell .Content = string (pending ) 206pending = pending [:0 ] 207 } 208cell .Style = style 209cell .Link = link 210 211if s == nil { 212// Building lines: unwrapped, no bounds 213if y >= len (lines ) { 214lines = append (lines ,Line {}) 215 } 216lines [y ]= append (lines [y ],cell ) 217lastX ,lastY = len (lines [y ])- 1 ,y 218x += width 219 }else { 220// Drawing to screen: handle wrapping, truncation, and bounds 221if ! truncate && x + cell .Width > bounds .Max .X && y + 1 < bounds .Max .Y { 222// Wrap the string to the width of the window 223x = bounds .Min .X 224y ++ 225 } 226 227pos := Pos (x ,y ) 228if pos .In (bounds ) { 229if truncate && tailc .Width > 0 && x + cell .Width > bounds .Max .X - tailc .Width { 230// Truncate the string and append the tail if any. 231cell = tailc 232cell .Style = style 233cell .Link = link 234s .SetCell (x ,y ,& cell ) 235lastX ,lastY = x ,y 236x += tailc .Width 237 }else { 238// Print the cell to the screen 239s .SetCell (x ,y ,& cell ) 240lastX ,lastY = x ,y 241x += width 242 } 243 } 244 } 245 246// Reset cell for next iteration 247cell = Cell {} 248default : 249// Valid sequences always have a non-zero Cmd. 250// TODO: Handle cursor movement and other sequences 251switch { 252case ansi .HasCsiPrefix (seq )&& p .Command ()== 'm' : 253// SGR - Select Graphic Rendition 254ReadStyle (p .Params (),& style ) 255case ansi .HasOscPrefix (seq )&& p .Command ()== 8 : 256// Hyperlinks 257ReadLink (p .Data (),& link ) 258case ansi .Equal (seq ,T ("\n" )): 259if s == nil { 260// When building lines, we need to ensure empty lines are represented. 261if y >= len (lines ) { 262lines = append (lines ,Line {}) 263 } 264 } 265y ++ 266// Always treat a NL as CR-LF similar to Termios ONLCR. 267fallthrough 268case ansi .Equal (seq ,T ("\r" )): 269if s == nil { 270x = 0 271 }else { 272x = bounds .Min .X 273 } 274case 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. 281if s == nil { 282if lastY >= 0 && lastY < len (lines )&& lastX < len (lines [lastY ]) { 283lines [lastY ][lastX ].Content += string (seq ) 284 } 285 }else if lastX >= 0 { 286if prev := s .CellAt (lastX ,lastY );prev != nil { 287folded := * prev 288folded .Content += string (seq ) 289s .SetCell (lastX ,lastY ,& folded ) 290 } 291 } 292case passThrough (seq ): 293pending = append (pending ,string (seq )... ) 294 } 295 } 296 297// Advance the state and data 298state = newState 299str = str [n :] 300 301if s != nil && y >= bounds .Max .Y { 302// We've reached the bottom of the bounds, stop processing further 303// lines. 304break 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. 312if len (pending )> 0 { 313if s == nil { 314if lastY >= 0 && lastY < len (lines )&& lastX >= 0 && lastX < len (lines [lastY ]) { 315lines [lastY ][lastX ].Content += string (pending ) 316 } 317 }else if lastX >= 0 { 318if prev := s .CellAt (lastX ,lastY );prev != nil { 319folded := * prev 320folded .Content += string (pending ) 321s .SetCell (lastX ,lastY ,& folded ) 322 } 323 } 324 } 325 326// Make sure to set the last cell if it's not empty. 327if ! cell .IsZero ()&& s != nil { 328s .SetCell (x ,y ,& cell ) 329 } 330 331return 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 ) { 337if len (params )== 0 { 338* pen = Style {} 339return 340 } 341 342for i := 0 ;i < len (params );i ++ { 343param ,hasMore ,_ := params .Param (i ,0 ) 344switch param { 345case 0 :// Reset 346* pen = Style {} 347case 1 :// Bold 348pen .Attrs |= AttrBold 349case 2 :// Dim/Faint 350pen .Attrs |= AttrFaint 351case 3 :// Italic 352pen .Attrs |= AttrItalic 353case 4 :// Underline 354nextParam ,_ ,ok := params .Param (i + 1 ,0 ) 355if hasMore && ok {// Only accept subparameters i.e. separated by ":" 356switch nextParam { 357case 0 ,1 ,2 ,3 ,4 ,5 : 358i ++ 359switch nextParam { 360case 0 :// No Underline 361pen .Underline = UnderlineStyleNone 362case 1 :// Single Underline 363pen .Underline = UnderlineStyleSingle 364case 2 :// Double Underline 365pen .Underline = UnderlineStyleDouble 366case 3 :// Curly Underline 367pen .Underline = UnderlineStyleCurly 368case 4 :// Dotted Underline 369pen .Underline = UnderlineStyleDotted 370case 5 :// Dashed Underline 371pen .Underline = UnderlineStyleDashed 372 } 373 } 374 }else { 375// Single Underline 376pen .Underline = UnderlineStyleSingle 377 } 378case 5 :// Slow Blink 379pen .Attrs |= AttrBlink 380case 6 :// Rapid Blink 381pen .Attrs |= AttrRapidBlink 382case 7 :// Reverse 383pen .Attrs |= AttrReverse 384case 8 :// Conceal 385pen .Attrs |= AttrConceal 386case 9 :// Crossed-out/Strikethrough 387pen .Attrs |= AttrStrikethrough 388case 22 :// Normal Intensity (not bold or faint) 389pen .Attrs &^= (AttrBold | AttrFaint ) 390case 23 :// Not italic, not Fraktur 391pen .Attrs &^=AttrItalic 392case 24 :// Not underlined 393pen .Underline = UnderlineStyleNone 394case 25 :// Blink off 395pen .Attrs &^= (AttrBlink | AttrRapidBlink ) 396case 27 :// Positive (not reverse) 397pen .Attrs &^=AttrReverse 398case 28 :// Reveal 399pen .Attrs &^=AttrConceal 400case 29 :// Not crossed out 401pen .Attrs &^=AttrStrikethrough 402case 30 ,31 ,32 ,33 ,34 ,35 ,36 ,37 :// Set foreground 403pen .Fg = ansi .Black + ansi .BasicColor (param - 30 )//nolint:gosec 404case 38 :// Set foreground 256 or truecolor 405var c color.Color 406n := ansi .ReadStyleColor (params [i :],& c ) 407if n > 0 { 408pen .Fg = c 409i += n - 1 410 } 411case 39 :// Default foreground 412pen .Fg = nil 413case 40 ,41 ,42 ,43 ,44 ,45 ,46 ,47 :// Set background 414pen .Bg = ansi .Black + ansi .BasicColor (param - 40 )//nolint:gosec 415case 48 :// Set background 256 or truecolor 416var c color.Color 417n := ansi .ReadStyleColor (params [i :],& c ) 418if n > 0 { 419pen .Bg = c 420i += n - 1 421 } 422case 49 :// Default Background 423pen .Bg = nil 424case 58 :// Set underline color 425var c color.Color 426n := ansi .ReadStyleColor (params [i :],& c ) 427if n > 0 { 428pen .UnderlineColor = c 429i += n - 1 430 } 431case 59 :// Default underline color 432pen .UnderlineColor = nil 433case 90 ,91 ,92 ,93 ,94 ,95 ,96 ,97 :// Set bright foreground 434pen .Fg = ansi .BrightBlack + ansi .BasicColor (param - 90 )//nolint:gosec 435case 100 ,101 ,102 ,103 ,104 ,105 ,106 ,107 :// Set bright background 436pen .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. 445params := bytes .SplitN (p , []byte {';' },3 ) 446if len (params )!= 3 { 447return 448 } 449link .Params = string (params [1 ]) 450link .URL = string (params [2 ]) 451}