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
}
// 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
)
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
ansiBlue = 4
ansiMagenta = 5
ansiCyan = 6
)
var queriedPalette = []int{ansiRed, ansiGreen, 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()
}
return out, true
}
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
}