okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
e4c3bd6
main
1package tui 2 3import ( 4"cmp" 5"crypto/sha256" 6"fmt" 7"regexp" 8"strings" 9"sync" 10 11"charm.land/glamour/v2" 12"charm.land/glamour/v2/ansi" 13"charm.land/glamour/v2/styles" 14"github.com/alecthomas/chroma/v2" 15"github.com/alecthomas/chroma/v2/formatters" 16 chromastyles"github.com/alecthomas/chroma/v2/styles" 17) 18 19// HighlightCode colors one line of code the way a reply's code blocks are 20// colored: the lexer chroma matched to the file, in the theme built from the 21// terminal's palette. A line no lexer claims stays as it is. 22func HighlightCode (line string ,lexer chroma.Lexer )string { 23if lexer == nil { 24return line 25 } 26tokens ,err := chroma .Coalesce (lexer ).Tokenise (nil ,line ) 27if err != nil { 28return line 29 } 30var b strings.Builder 31if formatters .TTY256 .Format (& b ,chromastyles .Get (codeTheme (transcriptInk )),tokens )!= nil { 32return line 33 } 34// some lexers end every input with a newline, which would wrap a row 35return strings .ReplaceAll (b .String (),"\n" ,"" ) 36} 37 38var markdownThemes sync.Mutex 39 40// fenceOpen and fenceClose mark the rows that frame a code block. 41const ( 42fenceOpen = "┌─" 43fenceClose = "└" 44) 45 46// codeFence is a code block's frame row in Decor, and quoteBar leads each 47// quoted row. Both are decoration, which a copy does not carry. 48func codeFence (mark string )string {return decorInk ()+ mark + ansiReset } 49func quoteBar ()string {return decorInk ()+ "│ " + ansiReset } 50 51// Chroma themes are registered by name; key them by the detected terminal 52// palette so a theme change cannot reuse the previous palette's colors. 53func codeTheme (colors ink )string { 54if colors .code == "" { 55return "monokai" 56 } 57palette := colors .code + colors .secondary + colors .brandFrom + colors .brandTo 58hash := sha256 .Sum256 ([]byte (palette )) 59name := fmt .Sprintf ("albedo-%x" ,hash [:6 ]) 60markdownThemes .Lock () 61defer markdownThemes .Unlock () 62if chromastyles .Registry [name ]== nil { 63chromastyles .Register (chroma .MustNewStyle (name , chroma.StyleEntries { 64chroma .Text :colors .code , 65chroma .Comment :colors .secondary + " italic" , 66chroma .Keyword :colors .brandFrom , 67chroma .NameFunction :colors .brandTo , 68chroma .LiteralString :colors .brandTo , 69chroma .LiteralNumber :colors .brandFrom , 70 })) 71 } 72return name 73} 74 75// accent is a markdown accent color, or the palette slot when the terminal 76// did not report its colors. 77func accent (hex ,slot string )* string {return new (cmp.Or (hex ,slot )) } 78 79func markdownStyle () ansi.StyleConfig { 80style := styles .NoTTYStyleConfig 81zero ,bold := uint (0 ),true 82style .Document .Margin = & zero 83style .Document .BlockPrefix ,style .Document .BlockSuffix = "" ,"" 84style .Heading .BlockSuffix = "" 85for _ ,heading := range []* ansi.StyleBlock {& style .H1 ,& style .H2 ,& style .H3 ,& style .H4 ,& style .H5 ,& style .H6 } { 86heading .Prefix = "" 87heading .Bold = & bold 88heading .Color = accent (transcriptInk .heading ,"5" ) 89 } 90style .Strong = ansi.StylePrimitive {Bold :& bold ,Color :accent (transcriptInk .strong ,"3" )} 91style .Emph = ansi.StylePrimitive {Italic :& bold } 92style .Code = ansi.StyleBlock {StylePrimitive : ansi.StylePrimitive {Color :accent (transcriptInk .inlineCode ,"2" )}} 93style .LinkText = ansi.StylePrimitive {Color :accent (transcriptInk .link ,"4" )} 94style .List .LevelIndent = 0 95style .Table .Margin = & zero 96style .BlockQuote .IndentToken = new (quoteBar ()) 97style .CodeBlock .Margin = & zero 98style .CodeBlock .BlockPrefix = codeFence (fenceOpen )+ "\n" 99style .CodeBlock .BlockSuffix = codeFence (fenceClose ) 100style .HorizontalRule .Format = "\n────────────\n" 101if transcriptInk .decor != "" { 102style .HorizontalRule .Color = & transcriptInk .decor 103 } 104style .CodeBlock .Theme = codeTheme (transcriptInk ) 105return style 106} 107 108// RenderMarkdownAnsi renders text block by block. Glamour spaces a block by 109// the one before it and no further, so each block renders once. 110func RenderMarkdownAnsi (text string ,width int )string { 111return renderBlocks (text ,cmp .Or (max (0 ,width ),80 ),true ) 112} 113 114// renderGrowing is RenderMarkdownAnsi for a reply streaming in: its last 115// block is still growing, so it is rendered again each time and not kept. 116func renderGrowing (text string ,width int )string { 117return renderBlocks (text ,cmp .Or (max (0 ,width ),80 ),false ) 118} 119 120// renderCopyable renders text to width with its wrapped rows marked, so a 121// copy can join them back into the lines they were. 122func renderCopyable (text string ,width int )string { 123return markWraps (RenderMarkdownAnsi (text ,width ),renderBlocks (text ,0 ,true )) 124} 125 126// renderBlocks is text rendered at width, or unwrapped at 0. Unless whole, 127// its last block may still grow and is not cached. 128func renderBlocks (text string ,width int ,whole bool )string { 129blocks := markdownBlocks (text ) 130var b strings.Builder 131for i ,block := range blocks { 132prev := "" 133if i > 0 { 134prev = blocks [i - 1 ] 135 } 136piece ,ok := markdownPiece (prev ,block ,width ,whole || i < len (blocks )- 1 ) 137if ! ok { 138return strings .Trim (renderMarkdown (text ,width )," \n" ) 139 } 140b .WriteString (piece ) 141 } 142return strings .Trim (b .String ()," \n" ) 143} 144 145// markdownPiece is what block adds to the rendering of prev, or false when 146// rendering prev on its own does not begin the rendering of both. Only a 147// finished block is kept: the last one may still be growing. 148func markdownPiece (prev ,block string ,width int ,finished bool ) (string ,bool ) { 149key := pieceKey {width :width ,prev :prev ,block :block } 150if piece ,ok := pieces .get (key );ok { 151return piece ,true 152 } 153whole ,head := renderMarkdown (prev + block ,width ),"" 154if prev != "" { 155head ,_ = markdownPiece ("" ,prev ,width ,true ) 156 } 157if ! strings .HasPrefix (whole ,head ) { 158return "" ,false 159 } 160piece := whole [len (head ):] 161if finished { 162pieces .put (key ,piece ) 163 } 164return piece ,true 165} 166 167// rendererKey is what a glamour renderer is built for. 168type rendererKey struct { 169ink ink 170width int 171} 172 173// renderers keeps the glamour renderer for the widths in use: building one 174// costs more than most renders it does. A resize adds a width, so the map is 175// emptied once it holds a handful. 176var renderers = struct { 177 sync.Mutex 178byKey map [rendererKey ]* glamour.TermRenderer 179}{byKey :map [rendererKey ]* glamour.TermRenderer {}} 180 181func markdownRenderer (width int ) (* glamour.TermRenderer ,error ) { 182key := rendererKey {ink :transcriptInk ,width :width } 183if renderer ,ok := renderers .byKey [key ];ok { 184return renderer ,nil 185 } 186renderer ,err := glamour .NewTermRenderer ( 187glamour .WithStyles (markdownStyle ()),glamour .WithWordWrap (width ),glamour .WithPreservedNewLines (), 188mathOptions (accent (transcriptInk .math ,"6" )), 189 ) 190if err != nil { 191return nil ,err 192 } 193if len (renderers .byKey )>= 4 { 194clear (renderers .byKey ) 195 } 196renderers .byKey [key ]= renderer 197return renderer ,nil 198} 199 200func renderMarkdown (text string ,width int )string { 201renderers .Lock () 202defer renderers .Unlock () 203renderer ,err := markdownRenderer (width ) 204if err != nil { 205return text 206 } 207markdownThemes .Lock () 208defer markdownThemes .Unlock () 209rendered ,err := renderer .Render (text ) 210if err != nil { 211return text 212 } 213return rendered 214} 215 216var ( 217orderedItem = regexp .MustCompile (`^\d{1,9}[.)](\s|$)` ) 218// Link definitions and raw HTML blocks can reach across blank lines. 219spansBlocks = regexp .MustCompile (`(?i)^ {0,3}(\[[^\]]+\]:|<(pre|script|style|textarea)\b|<!--)` ) 220) 221 222// markdownBlocks splits text before every line that has to open a new 223// top-level block: one at the margin after a blank line, outside a fence, 224// that cannot continue a list or quote above it. Text that can reach across 225// blank lines outside a fence keeps it whole. 226func markdownBlocks (text string ) []string { 227var blocks []string 228start ,fence ,blank := 0 ,"" ,false 229for pos := 0 ;pos < len (text ); { 230i := strings .IndexByte (text [pos :],'\n' ) 231end := len (text ) 232if i >= 0 { 233end = pos + i + 1 234 } 235line := strings .TrimRight (text [pos :end ],"\r\n" ) 236if fence == "" && spansBlocks .MatchString (line ) { 237return []string {text } 238 } 239if fence == "" && blank && pos > start && opensBlock (line ) { 240blocks = append (blocks ,text [start :pos ]) 241start = pos 242 } 243fence = fenceAfter (fence ,line ) 244blank = strings .TrimSpace (line )== "" 245pos = end 246 } 247return append (blocks ,text [start :]) 248} 249 250func opensBlock (line string )bool { 251return line != "" && strings .IndexByte (" \t-*+>|" ,line [0 ])< 0 && ! orderedItem .MatchString (line ) 252} 253 254// fenceAfter is the code fence or display math open after line, given the 255// one open before. Display math is kept by its closer, $$ or \]. 256func fenceAfter (open ,line string )string { 257if open == "$$" || open == `\]` { 258if strings .Contains (line ,open ) { 259return "" 260 } 261return open 262 } 263trimmed := strings .TrimLeft (line ," " ) 264if len (line )- len (trimmed )> 3 || trimmed == "" { 265return open 266 } 267if d ,_ ,closed ,ok := openDisplayMath ([]byte (trimmed ));open == "" && ok && ! closed { 268return d .close 269 } 270if trimmed [0 ]!= '`' && trimmed [0 ]!= '~' { 271return open 272 } 273run := trimmed [:len (trimmed )- len (strings .TrimLeft (trimmed ,trimmed [:1 ]))] 274switch { 275case len (run )< 3 : 276return open 277case open == "" : 278return run 279case run [0 ]== open [0 ]&& len (run )>= len (open )&& strings .TrimSpace (trimmed [len (run ):])== "" : 280return "" 281 } 282return open 283} 284 285type pieceKey struct { 286prev ,block string 287width int 288} 289 290// pieceCache keeps rendered pieces in two generations: a hit moves a piece 291// into the fresh one, and the stale one is dropped once the fresh one fills, 292// so the pieces the transcript still shows stay cached. Each generation holds 293// at most maxPieceBytes of key and rendered text, excluding map overhead. 294type pieceCache struct { 295fresh ,stale map [pieceKey ]string 296ink ink 297bytes int 298mu sync.Mutex 299} 300 301const maxPieceBytes = 1 << 20 302 303var pieces pieceCache 304 305func (c * pieceCache )get (key pieceKey ) (string ,bool ) { 306c .mu .Lock () 307defer c .mu .Unlock () 308if c .ink != transcriptInk { 309c .ink ,c .fresh ,c .stale ,c .bytes = transcriptInk ,nil ,nil ,0 310 } 311if piece ,ok := c .fresh [key ];ok { 312return piece ,true 313 } 314piece ,ok := c .stale [key ] 315if ok { 316c .store (key ,piece ) 317 } 318return piece ,ok 319} 320 321// put keeps copies: the key and piece are slices of one moment's stream and 322// its rendering, and would hold all of it. 323func (c * pieceCache )put (key pieceKey ,piece string ) { 324if len (key .prev )+ len (key .block )+ len (piece )> maxPieceBytes { 325return 326 } 327c .mu .Lock () 328defer c .mu .Unlock () 329if _ ,exists := c .fresh [key ];exists { 330return 331 } 332key .prev ,key .block = strings .Clone (key .prev ),strings .Clone (key .block ) 333c .store (key ,strings .Clone (piece )) 334} 335 336func (c * pieceCache )store (key pieceKey ,piece string ) { 337if _ ,exists := c .fresh [key ];exists { 338return 339 } 340size := len (key .prev )+ len (key .block )+ len (piece ) 341if size > maxPieceBytes { 342return 343 } 344if c .fresh == nil || size > maxPieceBytes - c .bytes { 345c .stale ,c .fresh ,c .bytes = c .fresh ,map [pieceKey ]string {},0 346 } 347c .fresh [key ]= piece 348c .bytes += size 349}