okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
e4c3bd6
main
1package tui 2 3import ( 4"fmt" 5"math" 6"os" 7"regexp" 8"strconv" 9 10"github.com/lucasb-eyer/go-colorful" 11) 12 13// ink is the transcript's brightness ramp. Each step is mixed from the 14// terminal's own text and background colors to a contrast target, so the 15// ramp keeps its spacing on any theme instead of trusting palette slots. 16type ink struct { 17// code steps down from prose for inline and fenced code. 18code string 19// secondary is text read at a glance: tool rows, timings, notices. 20secondary string 21// decor is structure that is seen, not read: list markers, gutters. 22decor string 23// busy is albedo's rail color while it works in tools. 24busy string 25// codeBg tints the cells under inline code, so a span keeps prose 26// contrast and still reads as code. 27codeBg string 28// surface lifts a selected row off the background. 29surface string 30// added and removed are the diff row tints. 31added ,removed string 32// brandFrom and brandTo are the ends of the brand gradient. 33brandFrom ,brandTo string 34// bg is the terminal's background, which language colors adapt to. 35bg string 36// heading, strong, inlineCode, link and math color markdown's marks, 37// each a palette hue made legible on bg. 38heading ,strong ,inlineCode ,link ,math string 39} 40 41// APCA contrast targets (Lc) for each step: about 60 suits content text, 42// 45 text read at a glance, and 15 to 30 marks that are not read. 43// The code tint is a luminance step from the background, measured as a 44// contrast ratio with the flare term so a black background gets a larger lift 45// than a gray one. Dark themes tint toward the palette's blue, light themes 46// step toward the text, since a blue wash on a light background reads as a 47// marker highlight. 48const ( 49darkCodeBgRatio = 1.3 50lightCodeBgRatio = 1.65 51surfaceRatio = 1.25 52diffRatio = 1.35 53) 54 55const ( 56codeLc = 64 57secondaryLc = 45 58decorLc = 22 59busyLc = 30 60// headings and strong text stand out from prose; inline code, links 61// and math step down to code's contrast 62headingLc = 75 63) 64 65type rgb struct {r ,g ,b float64 } 66 67func (c rgb )hex ()string { 68b := func (v float64 )int {return int (math .Round (math .Max (0 ,math .Min (1 ,v ))* 255 )) } 69return "#" + strconv .FormatInt (int64 (1 << 24 | b (c .r )<< 16 | b (c .g )<< 8 | b (c .b )),16 )[1 :] 70} 71 72func (c rgb )mix (to rgb ,t float64 )rgb { 73return rgb {c .r + (to .r - c .r )* t ,c .g + (to .g - c .g )* t ,c .b + (to .b - c .b )* t } 74} 75 76// apca is the magnitude of the APCA 0.0.98G lightness contrast of text on bg. 77func apca (text ,bg rgb )float64 { 78y := func (c rgb )float64 { 79v := 0.2126729 * math .Pow (c .r ,2.4 )+ 0.7151522 * math .Pow (c .g ,2.4 )+ 0.0721750 * math .Pow (c .b ,2.4 ) 80if v < 0.022 { 81v += math .Pow (0.022 - v ,1.414 ) 82 } 83return v 84 } 85yt ,yb := y (text ),y (bg ) 86var s float64 87if yb > yt { 88s = (math .Pow (yb ,0.56 )- math .Pow (yt ,0.57 ))* 1.14 89 }else { 90s = (math .Pow (yb ,0.65 )- math .Pow (yt ,0.62 ))* 1.14 91 } 92if math .Abs (s )< 0.1 { 93return 0 94 } 95return (math .Abs (s )- 0.027 )* 100 96} 97 98// bisect narrows [0, 1] to the boundary where still stops holding, over 24 99// halvings. Both mix searches are monotone in the fraction. It returns the 100// last fraction where still holds and the first where it does not; callers 101// pick the side that meets their target. 102func bisect (still func (t float64 )bool ) (lo ,hi float64 ) { 103lo ,hi = 0.0 ,1.0 104for range 24 { 105mid := (lo + hi )/ 2 106if still (mid ) { 107lo = mid 108 }else { 109hi = mid 110 } 111 } 112return lo ,hi 113} 114 115// toward mixes from into bg until it reaches the target contrast. A color 116// already under the target stays as it is. 117func toward (from ,bg rgb ,target float64 )rgb { 118if apca (from ,bg )<= target { 119return from 120 } 121t ,_ := bisect (func (t float64 )bool {return apca (from .mix (bg ,t ),bg )> target }) 122return from .mix (bg ,t ) 123} 124 125// luminance is relative luminance as the WCAG contrast ratio defines it. 126func luminance (c rgb )float64 { 127lin := func (v float64 )float64 { 128if v <= 0.04045 { 129return v / 12.92 130 } 131return math .Pow ((v + 0.055 )/ 1.055 ,2.4 ) 132 } 133return 0.2126 * lin (c .r )+ 0.7152 * lin (c .g )+ 0.0722 * lin (c .b ) 134} 135 136// ratio is the WCAG contrast ratio between two colors. 137func ratio (a ,b rgb )float64 { 138la ,lb := luminance (a )+ 0.05 ,luminance (b )+ 0.05 139return max (la ,lb )/ min (la ,lb ) 140} 141 142// step mixes bg toward to until the two differ by the given contrast ratio, 143// or reaches to when it cannot. 144func step (bg ,to rgb ,target float64 )rgb { 145if ratio (bg ,to )<= target { 146return to 147 } 148// hi is the first fraction that meets the ratio; lo only approaches it. 149_ ,t := bisect (func (t float64 )bool {return ratio (bg ,bg .mix (to ,t ))< target }) 150return bg .mix (to ,t ) 151} 152 153// legible moves c's OKLCH lightness away from bg, up on a dark background 154// and down on a light one, until its APCA contrast reaches target. Hue and 155// chroma stay, so a language keeps its color on every theme. 156func legible (c ,bg rgb ,target float64 )rgb { 157if apca (c ,bg )>= target { 158return c 159 } 160l ,chroma ,hue := colorful.Color {R :c .r ,G :c .g ,B :c .b }.OkLch () 161end := 1.0 162if bgL ,_ ,_ := (colorful.Color {R :bg .r ,G :bg .g ,B :bg .b }).OkLch ();bgL > 0.6 { 163end = 0 164 } 165at := func (t float64 )rgb { 166v := colorful .OkLch (l + (end - l )* t ,chroma ,hue ).Clamped () 167return rgb {v .R ,v .G ,v .B } 168 } 169_ ,t := bisect (func (t float64 )bool {return apca (at (t ),bg )< target }) 170return at (t ) 171} 172 173// termColors is what the terminal reported. A missing color is nil, and 174// palette holds only the entries that were reported. 175type termColors struct { 176fg ,bg * rgb 177palette map [int ]rgb 178} 179 180// The palette entries the theme mixes from. 181const ( 182ansiRed = 1 183ansiGreen = 2 184ansiYellow = 3 185ansiBlue = 4 186ansiMagenta = 5 187ansiCyan = 6 188) 189 190var queriedPalette = []int {ansiRed ,ansiGreen ,ansiYellow ,ansiBlue ,ansiMagenta ,ansiCyan } 191 192// minTrustLc is the least contrast a reported text color may have with the 193// reported background. Below it the reply describes something other than 194// what is on screen, like a multiplexer that answers with black for colors 195// it does not know, and the palette fallbacks are safer. 196const minTrustLc = 60 197 198func mixInk (c termColors ) (ink ,bool ) { 199if c .fg == nil || c .bg == nil || apca (* c .fg ,* c .bg )< minTrustLc { 200return ink {},false 201 } 202fg ,bg := * c .fg ,* c .bg 203out := ink { 204code :toward (fg ,bg ,codeLc ).hex (), 205secondary :toward (fg ,bg ,secondaryLc ).hex (), 206decor :toward (fg ,bg ,decorLc ).hex (), 207surface :step (bg ,fg ,surfaceRatio ).hex (), 208bg :bg .hex (), 209 } 210blue ,hasBlue := c .palette [ansiBlue ] 211codeTarget ,codeRatio := fg ,darkCodeBgRatio 212if luminance (bg )> luminance (fg ) { 213codeRatio = lightCodeBgRatio 214 }else if hasBlue { 215codeTarget = blue 216 } 217out .codeBg = step (bg ,codeTarget ,codeRatio ).hex () 218if magenta ,ok := c .palette [ansiMagenta ];ok && apca (magenta ,bg )>= busyLc { 219out .busy = toward (magenta ,bg ,busyLc ).hex () 220 } 221if red ,ok := c .palette [ansiRed ];ok { 222out .removed = step (bg ,red ,diffRatio ).hex () 223 } 224if green ,ok := c .palette [ansiGreen ];ok { 225out .added = step (bg ,green ,diffRatio ).hex () 226 } 227// the brand runs from your color toward violet, halfway between the 228// palette's blue and magenta 229cyan ,hasCyan := c .palette [ansiCyan ] 230magenta ,hasMagenta := c .palette [ansiMagenta ] 231if hasCyan && hasBlue && hasMagenta { 232out .brandFrom ,out .brandTo = cyan .hex (),blue .mix (magenta ,0.5 ).hex () 233 } 234markdownAccents (& out ,c .palette ,bg ) 235return out ,true 236} 237 238// markdownAccents colors markdown from the palette: headings in the 239// brand's violet, strong text in the yellow pulled toward red, inline code 240// green, links blue and math cyan. 241func markdownAccents (out * ink ,palette map [int ]rgb ,bg rgb ) { 242onBg := func (c rgb ,target float64 )string {return legible (c ,bg ,target ).hex () } 243red ,hasRed := palette [ansiRed ] 244if yellow ,ok := palette [ansiYellow ];ok { 245if hasRed { 246yellow = yellow .mix (red ,0.35 ) 247 } 248out .strong = onBg (yellow ,headingLc ) 249 } 250blue ,hasBlue := palette [ansiBlue ] 251if magenta ,ok := palette [ansiMagenta ];ok && hasBlue { 252out .heading = onBg (blue .mix (magenta ,0.5 ),headingLc ) 253 } 254if hasBlue { 255out .link = onBg (blue ,codeLc ) 256 } 257if green ,ok := palette [ansiGreen ];ok { 258out .inlineCode = onBg (green ,codeLc ) 259 } 260if cyan ,ok := palette [ansiCyan ];ok { 261out .math = onBg (cyan ,codeLc ) 262 } 263} 264 265var oscColor = regexp .MustCompile (`\x1b\](10|11|4;(\d+));rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})` ) 266 267// parseColors reads OSC 10, 11 and 4 replies, whose channels carry one to 268// four hex digits each. 269func parseColors (reply string )termColors { 270c := termColors {palette :map [int ]rgb {}} 271for _ ,m := range oscColor .FindAllStringSubmatch (reply ,- 1 ) { 272channel := func (h string )float64 { 273v ,_ := strconv .ParseUint (h ,16 ,16 ) 274return float64 (v )/ float64 (uint64 (1 )<< (4 * len (h ))- 1 ) 275 } 276col := & rgb {channel (m [3 ]),channel (m [4 ]),channel (m [5 ])} 277switch m [1 ] { 278case "10" : 279c .fg = col 280case "11" : 281c .bg = col 282default : 283index ,_ := strconv .Atoi (m [2 ]) 284c .palette [index ]= * col 285 } 286 } 287return c 288} 289 290var transcriptInk ink 291 292// DetectInk asks the terminal for its colors and mixes the transcript's 293// ramp from them. It must run before the TUI owns the terminal's input. 294func DetectInk ()error { 295reply ,err := queryColors () 296if err != nil { 297return err 298 } 299detected ,ok := mixInk (parseColors (reply )) 300if path := os .Getenv ("ALBEDO_INK_DEBUG" );path != "" { 301_ = os .WriteFile (path ,fmt .Appendf (nil ,"reply %q\ntrusted %v\nink %+v\n" ,reply ,ok ,detected ),0o644 ) 302 } 303if ok { 304useInk (detected ) 305 } 306return nil 307}