clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

feat/app-decomposition
puzzletea / theme / contrast.go
contrast.go 5.6K
1package theme23import (4 "image/color"5 "math"6)78// relativeLuminance returns the WCAG 2.x relative luminance of a color.9// See https://www.w3.org/TR/WCAG21/#dfn-relative-luminance10func relativeLuminance(c color.Color) float64 {11 r, g, b, _ := c.RGBA()12 // Convert from 16-bit pre-multiplied to 0..1 sRGB.13 sr := linearize(float64(r) / 0xffff)14 sg := linearize(float64(g) / 0xffff)15 sb := linearize(float64(b) / 0xffff)16 return 0.2126*sr + 0.7152*sg + 0.0722*sb17}1819// linearize converts an sRGB channel value (0..1) to linear light.20func linearize(v float64) float64 {21 if v <= 0.04045 {22 return v / 12.9223 }24 return math.Pow((v+0.055)/1.055, 2.4)25}2627// contrastRatio returns the WCAG 2.x contrast ratio between two colors.28// The result is in the range [1, 21].29func contrastRatio(a, b color.Color) float64 {30 la := relativeLuminance(a)31 lb := relativeLuminance(b)32 if la < lb {33 la, lb = lb, la34 }35 return (la + 0.05) / (lb + 0.05)36}3738// minContrastNormal is the WCAG AA minimum for normal text (4.5:1).39const minContrastNormal = 4.54041// minContrastLarge is the WCAG AA minimum for large/bold text (3:1).42const minContrastLarge = 3.04344const givenTintBlend = 0.254546// ensurePairContrast ensures that the foreground/background pair meets the47// minimum contrast ratio. When the original fg falls short, it scans the48// full 16-color ANSI palette for the candidate that meets the ratio and49// has the closest relative luminance to the original fg — preserving the50// intended lightness rather than jumping to an extreme.51//52// If no ANSI color qualifies, the closest-luminance ANSI candidate is53// nudged toward white or black just enough to satisfy the ratio, keeping54// the result close to the palette's tonal range.55func ensurePairContrast(fg, bg color.Color, p Palette, minRatio float64) color.Color {56 if contrastRatio(fg, bg) >= minRatio {57 return fg58 }5960 targetLum := relativeLuminance(fg)6162 // Find the closest-luminance ANSI color that meets contrast, and63 // separately track the closest-luminance ANSI color overall as a64 // nudge candidate if nothing qualifies outright.65 var bestOK, bestAny color.Color66 bestOKDist := math.Inf(1)67 bestAnyDist := math.Inf(1)6869 for _, c := range p.ANSI {70 dist := math.Abs(relativeLuminance(c) - targetLum)71 if dist < bestAnyDist {72 bestAny = c73 bestAnyDist = dist74 }75 if contrastRatio(c, bg) >= minRatio && dist < bestOKDist {76 bestOK = c77 bestOKDist = dist78 }79 }8081 if bestOK != nil {82 return bestOK83 }8485 // No ANSI color meets the ratio — nudge the closest candidate.86 return nudgeContrast(bestAny, bg, minRatio)87}8889// nudgeContrast blends fg toward white or black until the contrast ratio90// against bg meets minRatio. The direction (lighter or darker) is chosen91// by moving away from the background luminance, so the result stays as92// close to the original fg as possible.93func nudgeContrast(fg, bg color.Color, minRatio float64) color.Color {94 fgLum := relativeLuminance(fg)95 bgLum := relativeLuminance(bg)9697 type endpoint struct{ r, g, b uint8 }98 light := endpoint{0xff, 0xff, 0xff}99 dark := endpoint{0x00, 0x00, 0x00}100101 // Move away from the background luminance.102 target := light103 if fgLum <= bgLum {104 target = dark105 }106107 // If the chosen extreme can't reach minRatio, flip.108 tColor := color.NRGBA{R: target.r, G: target.g, B: target.b, A: 0xff}109 if contrastRatio(tColor, bg) < minRatio {110 if target == light {111 target = dark112 } else {113 target = light114 }115 }116117 fr, fg2, fb, _ := fg.RGBA()118 fr8, fg8, fb8 := uint8(fr>>8), uint8(fg2>>8), uint8(fb>>8)119120 // Binary search for the minimum blend factor.121 lo, hi := 0.0, 1.0122 for range 32 {123 mid := (lo + hi) / 2124 c := blendRGB(fr8, fg8, fb8, target.r, target.g, target.b, mid)125 if contrastRatio(c, bg) >= minRatio {126 hi = mid127 } else {128 lo = mid129 }130 }131 return blendRGB(fr8, fg8, fb8, target.r, target.g, target.b, hi)132}133134// blendRGB linearly interpolates between two RGB colors at factor t ∈ [0,1].135func blendRGB(r1, g1, b1, r2, g2, b2 uint8, t float64) color.Color {136 lerp := func(a, b uint8) uint8 {137 return uint8(float64(a)*(1-t) + float64(b)*t + 0.5)138 }139 return color.NRGBA{R: lerp(r1, r2), G: lerp(g1, g2), B: lerp(b1, b2), A: 0xff}140}141142// TextOnBG returns the current palette's FG or BG color — whichever has143// better contrast against bg. This is useful for choosing readable text144// over an arbitrary background (e.g. colored rectangles in shikaku).145func TextOnBG(bg color.Color) color.Color {146 p := Current()147 if contrastRatio(p.FG, bg) >= contrastRatio(p.BG, bg) {148 return p.FG149 }150 return p.BG151}152153// GivenTint shifts a color toward the current palette's Given color while154// preserving some of the original hue. This is used for immutable-cell155// styling cues in editable grids.156func GivenTint(c color.Color) color.Color {157 if c == nil {158 return Current().Given159 }160 return Blend(c, Current().Given, givenTintBlend)161}162163// Blend returns a color that is t fraction of the way from a toward b.164// t=0 returns a, t=1 returns b. Used to derive muted background colors165// from foreground accent colors.166func Blend(a, b color.Color, t float64) color.Color {167 ar, ag, ab, _ := a.RGBA()168 br, bg, bb, _ := b.RGBA()169 return blendRGB(uint8(ar>>8), uint8(ag>>8), uint8(ab>>8),170 uint8(br>>8), uint8(bg>>8), uint8(bb>>8), t)171}172173// MidTone returns a color perceptually halfway between a and b by174// blending their sRGB channels. This produces a muted tone suitable for175// dim/secondary text that is guaranteed to sit between the two anchors.176func MidTone(a, b color.Color) color.Color {177 ar, ag, ab, _ := a.RGBA()178 br, bg, bb, _ := b.RGBA()179 return color.NRGBA{180 R: uint8(((ar >> 8) + (br >> 8)) / 2),181 G: uint8(((ag >> 8) + (bg >> 8)) / 2),182 B: uint8(((ab >> 8) + (bb >> 8)) / 2),183 A: 0xff,184 }185}