v0.1.1 true color extracted to a standalone package, lut256 lazy build

This commit is contained in:
2026-07-14 13:04:27 -04:00
parent aa22225c61
commit 21041633b2
33 changed files with 480 additions and 745 deletions
+72 -71
View File
@@ -1,5 +1,12 @@
package terminal
import (
"sync"
"sync/atomic"
"github.com/lixenwraith/color"
)
// ColorMode indicates terminal color capability
type ColorMode uint8
@@ -8,85 +15,89 @@ const (
ColorModeTrueColor // 24-bit RGB
)
// RGB represents a 24-bit color
type RGB struct {
R uint8 `toml:"r"`
G uint8 `toml:"g"`
B uint8 `toml:"b"`
}
// Lerp linearly interpolates from c to other by t, clamped to [0,1]
func (c RGB) Lerp(other RGB, t float64) RGB {
if t <= 0 {
return c
}
if t >= 1 {
return other
}
return RGB{
R: clampU8(float64(c.R) + (float64(other.R)-float64(c.R))*t),
G: clampU8(float64(c.G) + (float64(other.G)-float64(c.G))*t),
B: clampU8(float64(c.B) + (float64(other.B)-float64(c.B))*t),
}
}
// RGBBlack is the zero value black color
var RGBBlack = RGB{0, 0, 0}
// 6-bit quantized LUT for Redmean-based 256-color mapping
// 64×64×64 = 262,144 bytes, fits in L2 cache
var lut256 [64 * 64 * 64]uint8
const lut256Size = 64 * 64 * 64
func init() {
// Pre-compute Redmean-based palette mapping for all 6-bit quantized RGB values
for r := 0; r < 64; r++ {
for g := 0; g < 64; g++ {
for b := 0; b < 64; b++ {
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
r8 := (r << 2) | 2
g8 := (g << 2) | 2
b8 := (b << 2) | 2
lut256[r<<12|g<<6|b] = computeRedmean256(r8, g8, b8)
}
}
// init() -> first-use build. Construction is ~63M Redmean evaluations
// and 256 KiB resident; truecolor sessions never read the table.
var (
lut256Ptr atomic.Pointer[[lut256Size]uint8]
lut256Once sync.Once
)
// lut256 returns the palette LUT, building it on first use.
// Fast path is an acquire load plus a predictable branch, and inlines into RGBTo256.
func lut256() *[lut256Size]uint8 {
if p := lut256Ptr.Load(); p != nil {
return p
}
return lut256Build()
}
// computeRedmean256 finds the nearest 256-palette index using Redmean distance
// Called only at init() to populate LUT
func computeRedmean256(r, g, b int) uint8 {
// lut256Build populates the table. Cold path, kept out of line so lut256 stays inlinable.
// The release store publishes the fully written array to acquire loads in lut256.
//
//go:noinline
func lut256Build() *[lut256Size]uint8 {
lut256Once.Do(func() {
t := new([lut256Size]uint8)
for r := range 64 {
for g := range 64 {
for b := range 64 {
// Expand 6-bit to 8-bit (shift left 2, add 2 for midpoint)
c := color.RGB{
R: uint8(r<<2 | 2),
G: uint8(g<<2 | 2),
B: uint8(b<<2 | 2),
}
t[r<<12|g<<6|b] = computeRedmean256(c)
}
}
}
lut256Ptr.Store(t)
})
return lut256Ptr.Load()
}
// WarmPalette256 forces LUT construction. Idempotent, safe for concurrent use.
// Call before the first render when ColorMode is ColorMode256.
// RGBTo256 runs on the render path under the terminal mutex; a lazy build there stalls the frame.
func WarmPalette256() { _ = lut256() }
// computeRedmean256 finds the nearest 256-palette index using Redmean distance called from lut256Build, not init()
func computeRedmean256(c color.RGB) uint8 {
// Grayscale fast path
if r == g && g == b {
if r < 8 {
if c.R == c.G && c.G == c.B {
if c.R < 8 {
return 16
}
if r > 238 {
if c.R > 238 {
return 231
}
return uint8(232 + (r-8)/10)
return uint8(232 + (int(c.R)-8)/10)
}
bestIdx := uint8(16)
minDist := 1 << 30
// Search 6×6×6 cube (indices 16-231)
for i := 0; i < 216; i++ {
cr := cubeValues[i/36]
cg := cubeValues[(i/6)%6]
cb := cubeValues[i%6]
d := redmeanDistance(r, g, b, cr, cg, cb)
if d < minDist {
for i := range 216 {
cand := color.RGB{
R: cubeValues[i/36],
G: cubeValues[(i/6)%6],
B: cubeValues[i%6],
}
if d := color.RedmeanDistance(c, cand); d < minDist {
minDist = d
bestIdx = uint8(16 + i)
}
}
// Search grayscale ramp (indices 232-255)
for i := 0; i < 24; i++ {
gray := 8 + i*10
d := redmeanDistance(r, g, b, gray, gray, gray)
if d < minDist {
for i := range 24 {
g := uint8(8 + i*10)
if d := color.RedmeanDistance(c, color.RGB{R: g, G: g, B: g}); d < minDist {
minDist = d
bestIdx = uint8(232 + i)
}
@@ -95,21 +106,11 @@ func computeRedmean256(r, g, b int) uint8 {
return bestIdx
}
// redmeanDistance calculates perceptually-weighted color distance
// Formula: https://en.wikipedia.org/wiki/Color_difference#sRGB
func redmeanDistance(r1, g1, b1, r2, g2, b2 int) int {
rmean := (r1 + r2) / 2
dr := r1 - r2
dg := g1 - g2
db := b1 - b2
return (((512 + rmean) * dr * dr) >> 8) + 4*dg*dg + (((767 - rmean) * db * db) >> 8)
}
// Color cube values for 6×6×6 palette (indices 16-231)
var cubeValues = [6]int{0, 95, 135, 175, 215, 255}
var cubeValues = [6]uint8{0, 95, 135, 175, 215, 255}
// RGBTo256 converts RGB to nearest 256-color palette index
// O(1) lookup via pre-computed Redmean LUT
func RGBTo256(c RGB) uint8 {
return lut256[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
// RGBTo256 converts RGB to nearest 256-color palette index.
// O(1) via the Redmean LUT; the first call builds it (see WarmPalette256).
func RGBTo256(c color.RGB) uint8 {
return lut256()[int(c.R>>2)<<12|int(c.G>>2)<<6|int(c.B>>2)]
}