package tui import ( "fmt" "math" "os" "regexp" "strconv" "github.com/lucasb-eyer/go-colorful" ) // ink is the transcript's brightness ramp. Each step is mixed from the // terminal's own text and background colors to a contrast target, so the // ramp keeps its spacing on any theme instead of trusting palette slots. type ink struct { // code steps down from prose for inline and fenced code. code string // secondary is text read at a glance: tool rows, timings, notices. secondary string // decor is structure that is seen, not read: list markers, gutters. decor string // busy is albedo's rail color while it works in tools. busy string // codeBg tints the cells under inline code, so a span keeps prose // contrast and still reads as code. codeBg string // surface lifts a selected row off the background. surface string // added and removed are the diff row tints. added, removed string // brandFrom and brandTo are the ends of the brand gradient. brandFrom, brandTo string // bg is the terminal's background, which language colors adapt to. bg string // heading, strong, inlineCode, link and math color markdown's marks, // each a palette hue made legible on bg. heading, strong, inlineCode, link, math string } // APCA contrast targets (Lc) for each step: about 60 suits content text, // 45 text read at a glance, and 15 to 30 marks that are not read. // The code tint is a luminance step from the background, measured as a // contrast ratio with the flare term so a black background gets a larger lift // than a gray one. Dark themes tint toward the palette's blue, light themes // step toward the text, since a blue wash on a light background reads as a // marker highlight. const ( darkCodeBgRatio = 1.3 lightCodeBgRatio = 1.65 surfaceRatio = 1.25 diffRatio = 1.35 ) const ( codeLc = 64 secondaryLc = 45 decorLc = 22 busyLc = 30 // headings and strong text stand out from prose; inline code, links // and math step down to code's contrast headingLc = 75 ) type rgb struct{ r, g, b float64 } func (c rgb) hex() string { b := func(v float64) int { return int(math.Round(math.Max(0, math.Min(1, v)) * 255)) } return "#" + strconv.FormatInt(int64(1<<24|b(c.r)<<16|b(c.g)<<8|b(c.b)), 16)[1:] } func (c rgb) mix(to rgb, t float64) rgb { return rgb{c.r + (to.r-c.r)*t, c.g + (to.g-c.g)*t, c.b + (to.b-c.b)*t} } // apca is the magnitude of the APCA 0.0.98G lightness contrast of text on bg. func apca(text, bg rgb) float64 { y := func(c rgb) float64 { v := 0.2126729*math.Pow(c.r, 2.4) + 0.7151522*math.Pow(c.g, 2.4) + 0.0721750*math.Pow(c.b, 2.4) if v < 0.022 { v += math.Pow(0.022-v, 1.414) } return v } yt, yb := y(text), y(bg) var s float64 if yb > yt { s = (math.Pow(yb, 0.56) - math.Pow(yt, 0.57)) * 1.14 } else { s = (math.Pow(yb, 0.65) - math.Pow(yt, 0.62)) * 1.14 } if math.Abs(s) < 0.1 { return 0 } return (math.Abs(s) - 0.027) * 100 } // bisect narrows [0, 1] to the boundary where still stops holding, over 24 // halvings. Both mix searches are monotone in the fraction. It returns the // last fraction where still holds and the first where it does not; callers // pick the side that meets their target. func bisect(still func(t float64) bool) (lo, hi float64) { lo, hi = 0.0, 1.0 for range 24 { mid := (lo + hi) / 2 if still(mid) { lo = mid } else { hi = mid } } return lo, hi } // toward mixes from into bg until it reaches the target contrast. A color // already under the target stays as it is. func toward(from, bg rgb, target float64) rgb { if apca(from, bg) <= target { return from } t, _ := bisect(func(t float64) bool { return apca(from.mix(bg, t), bg) > target }) return from.mix(bg, t) } // luminance is relative luminance as the WCAG contrast ratio defines it. func luminance(c rgb) float64 { lin := func(v float64) float64 { if v <= 0.04045 { return v / 12.92 } return math.Pow((v+0.055)/1.055, 2.4) } return 0.2126*lin(c.r) + 0.7152*lin(c.g) + 0.0722*lin(c.b) } // ratio is the WCAG contrast ratio between two colors. func ratio(a, b rgb) float64 { la, lb := luminance(a)+0.05, luminance(b)+0.05 return max(la, lb) / min(la, lb) } // step mixes bg toward to until the two differ by the given contrast ratio, // or reaches to when it cannot. func step(bg, to rgb, target float64) rgb { if ratio(bg, to) <= target { return to } // hi is the first fraction that meets the ratio; lo only approaches it. _, t := bisect(func(t float64) bool { return ratio(bg, bg.mix(to, t)) < target }) return bg.mix(to, t) } // legible moves c's OKLCH lightness away from bg, up on a dark background // and down on a light one, until its APCA contrast reaches target. Hue and // chroma stay, so a language keeps its color on every theme. func legible(c, bg rgb, target float64) rgb { if apca(c, bg) >= target { return c } l, chroma, hue := colorful.Color{R: c.r, G: c.g, B: c.b}.OkLch() end := 1.0 if bgL, _, _ := (colorful.Color{R: bg.r, G: bg.g, B: bg.b}).OkLch(); bgL > 0.6 { end = 0 } at := func(t float64) rgb { v := colorful.OkLch(l+(end-l)*t, chroma, hue).Clamped() return rgb{v.R, v.G, v.B} } _, t := bisect(func(t float64) bool { return apca(at(t), bg) < target }) return at(t) } // termColors is what the terminal reported. A missing color is nil, and // palette holds only the entries that were reported. type termColors struct { fg, bg *rgb palette map[int]rgb } // The palette entries the theme mixes from. const ( ansiRed = 1 ansiGreen = 2 ansiYellow = 3 ansiBlue = 4 ansiMagenta = 5 ansiCyan = 6 ) var queriedPalette = []int{ansiRed, ansiGreen, ansiYellow, ansiBlue, ansiMagenta, ansiCyan} // minTrustLc is the least contrast a reported text color may have with the // reported background. Below it the reply describes something other than // what is on screen, like a multiplexer that answers with black for colors // it does not know, and the palette fallbacks are safer. const minTrustLc = 60 func mixInk(c termColors) (ink, bool) { if c.fg == nil || c.bg == nil || apca(*c.fg, *c.bg) < minTrustLc { return ink{}, false } fg, bg := *c.fg, *c.bg out := ink{ code: toward(fg, bg, codeLc).hex(), secondary: toward(fg, bg, secondaryLc).hex(), decor: toward(fg, bg, decorLc).hex(), surface: step(bg, fg, surfaceRatio).hex(), bg: bg.hex(), } blue, hasBlue := c.palette[ansiBlue] codeTarget, codeRatio := fg, darkCodeBgRatio if luminance(bg) > luminance(fg) { codeRatio = lightCodeBgRatio } else if hasBlue { codeTarget = blue } out.codeBg = step(bg, codeTarget, codeRatio).hex() if magenta, ok := c.palette[ansiMagenta]; ok && apca(magenta, bg) >= busyLc { out.busy = toward(magenta, bg, busyLc).hex() } if red, ok := c.palette[ansiRed]; ok { out.removed = step(bg, red, diffRatio).hex() } if green, ok := c.palette[ansiGreen]; ok { out.added = step(bg, green, diffRatio).hex() } // the brand runs from your color toward violet, halfway between the // palette's blue and magenta cyan, hasCyan := c.palette[ansiCyan] magenta, hasMagenta := c.palette[ansiMagenta] if hasCyan && hasBlue && hasMagenta { out.brandFrom, out.brandTo = cyan.hex(), blue.mix(magenta, 0.5).hex() } markdownAccents(&out, c.palette, bg) return out, true } // markdownAccents colors markdown from the palette: headings in the // brand's violet, strong text in the yellow pulled toward red, inline code // green, links blue and math cyan. func markdownAccents(out *ink, palette map[int]rgb, bg rgb) { onBg := func(c rgb, target float64) string { return legible(c, bg, target).hex() } red, hasRed := palette[ansiRed] if yellow, ok := palette[ansiYellow]; ok { if hasRed { yellow = yellow.mix(red, 0.35) } out.strong = onBg(yellow, headingLc) } blue, hasBlue := palette[ansiBlue] if magenta, ok := palette[ansiMagenta]; ok && hasBlue { out.heading = onBg(blue.mix(magenta, 0.5), headingLc) } if hasBlue { out.link = onBg(blue, codeLc) } if green, ok := palette[ansiGreen]; ok { out.inlineCode = onBg(green, codeLc) } if cyan, ok := palette[ansiCyan]; ok { out.math = onBg(cyan, codeLc) } } var 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})`) // parseColors reads OSC 10, 11 and 4 replies, whose channels carry one to // four hex digits each. func parseColors(reply string) termColors { c := termColors{palette: map[int]rgb{}} for _, m := range oscColor.FindAllStringSubmatch(reply, -1) { channel := func(h string) float64 { v, _ := strconv.ParseUint(h, 16, 16) return float64(v) / float64(uint64(1)<<(4*len(h))-1) } col := &rgb{channel(m[3]), channel(m[4]), channel(m[5])} switch m[1] { case "10": c.fg = col case "11": c.bg = col default: index, _ := strconv.Atoi(m[2]) c.palette[index] = *col } } return c } var transcriptInk ink // DetectInk asks the terminal for its colors and mixes the transcript's // ramp from them. It must run before the TUI owns the terminal's input. func DetectInk() error { reply, err := queryColors() if err != nil { return err } detected, ok := mixInk(parseColors(reply)) if path := os.Getenv("ALBEDO_INK_DEBUG"); path != "" { _ = os.WriteFile(path, fmt.Appendf(nil, "reply %q\ntrusted %v\nink %+v\n", reply, ok, detected), 0o644) } if ok { useInk(detected) } return nil }