Add perceptual 16-colour mapping

Dark shades map to black and greys to the grey/white entries while
saturated category colours keep their hue, instead of the stock
conversion's bright yellow/cyan shadows.
This commit is contained in:
Andy Dixon
2026-10-02 08:38:36 +01:00
parent d7e1d3e620
commit cb8ae54258
4 changed files with 120 additions and 12 deletions
+31 -10
View File
@@ -232,7 +232,16 @@ func (cv *Canvas) Line(y int) string {
return b.String()
}
func sgr(b *strings.Builder, st Style, p256 bool) {
// colorDepth selects how colours are serialised.
type colorDepth uint8
const (
depthTrue colorDepth = iota
depth256
depth16
)
func sgr(b *strings.Builder, st Style, depth colorDepth) {
b.WriteString("\x1b[0")
if st.Attr&Bold != 0 {
b.WriteString(";1")
@@ -253,10 +262,22 @@ func sgr(b *strings.Builder, st Style, p256 bool) {
if c == 0 {
return
}
if p256 {
switch depth {
case depth256:
b.WriteString(";" + base + ";5;")
b.WriteString(strconv.Itoa(int(quant256(c))))
return
case depth16:
n := int(quant16(c))
code := 30 + n
if n >= 8 {
code = 90 + n - 8
}
if base == "48" {
code += 10
}
b.WriteString(";" + strconv.Itoa(code))
return
}
b.WriteString(";" + base + ";2;")
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
@@ -270,14 +291,14 @@ func sgr(b *strings.Builder, st Style, p256 bool) {
b.WriteByte('m')
}
// String serialises the canvas as lines of text with SGR styling. Colours
// are emitted as 24-bit, or as 256-colour indices from our own quantizer
// when p256 is set; Bubble Tea downsamples further (16 colours, none) to
// the terminal's profile.
func (cv *Canvas) String() string { return cv.Render(false) }
// String serialises the canvas as lines of text with SGR styling and
// 24-bit colours.
func (cv *Canvas) String() string { return cv.Render(depthTrue) }
// Render serialises the canvas; see String.
func (cv *Canvas) Render(p256 bool) string {
// Render serialises the canvas. For 256 and 16 colours it quantizes with
// its own perceptual mapping (see color256.go); Bubble Tea strips colour
// entirely for colourless terminals.
func (cv *Canvas) Render(depth colorDepth) string {
var b strings.Builder
b.Grow(cv.W * cv.H * 4)
for y := 0; y < cv.H; y++ {
@@ -292,7 +313,7 @@ func (cv *Canvas) Render(p256 bool) string {
continue
}
if first || c.st != cur {
sgr(&b, c.st, p256)
sgr(&b, c.st, depth)
cur, first = c.st, false
}
b.WriteString(c.s)
+51
View File
@@ -43,3 +43,54 @@ func quant256(c Color) uint8 {
q256Cache.Store(c, uint8(best))
return uint8(best)
}
var q16Cache sync.Map // Color -> uint8
// quant16 maps a colour to a basic colour. Dark shades become black and
// unsaturated colours grey/white, so tints and shadows stay quiet; saturated
// colours keep their hue (bright variant when light), so category colours
// survive. The stock conversion instead turns dark tints into bright
// yellow/cyan.
func quant16(c Color) uint8 {
if v, ok := q16Cache.Load(c); ok {
return v.(uint8)
}
rf, gf, bf := c.rgb()
mx, mn := max(rf, gf, bf), min(rf, gf, bf)
luma := c.Luma()
var n uint8
switch {
case luma < 0.2:
n = 0
case mx-mn < 0.12: // grey
switch {
case luma < 0.45:
n = 8
case luma < 0.8:
n = 7
default:
n = 15
}
default:
// Hue in sixths: red, yellow, green, cyan, blue, magenta.
var h float64
switch mx {
case rf:
h = math.Mod((gf-bf)/(mx-mn), 6)
case gf:
h = (bf-rf)/(mx-mn) + 2
default:
h = (rf-gf)/(mx-mn) + 4
}
if h < 0 {
h += 6
}
ansi := [6]uint8{1, 3, 2, 6, 4, 5}[int(math.Round(h))%6]
n = ansi
if luma > 0.5 {
n += 8
}
}
q16Cache.Store(c, n)
return n
}
+12 -2
View File
@@ -76,7 +76,7 @@ func (m *Model) render() string {
g := computeGeometry(m.w, m.h)
if g.class == tooSmall {
m.renderTooSmall(cv)
return cv.Render(m.profile == colorprofile.ANSI256)
return cv.Render(m.depth())
}
m.tree.RLock()
defer m.tree.RUnlock()
@@ -100,7 +100,17 @@ func (m *Model) render() string {
if len(m.modals) > 0 {
m.paintModal(cv)
}
return cv.Render(m.profile == colorprofile.ANSI256)
return cv.Render(m.depth())
}
func (m *Model) depth() colorDepth {
switch m.profile {
case colorprofile.ANSI256:
return depth256
case colorprofile.ANSI:
return depth16
}
return depthTrue
}
func (m *Model) renderTooSmall(cv *Canvas) {
+26
View File
@@ -454,3 +454,29 @@ func TestMouse(t *testing.T) {
t.Fatal("click outside modal should close it")
}
}
func TestQuantizers(t *testing.T) {
if quant16(RGB(10, 12, 8)) != 0 {
t.Fatal("near-black should map to black")
}
if n := quant16(RGB(0xe0, 0x6c, 0x75)); n != 1 && n != 9 {
t.Fatalf("red-ish mapped to %d", n)
}
if n := quant256(RGB(40, 48, 30)); n < 16 {
t.Fatalf("256 quantizer used palette-dependent index %d", n)
}
// A dark, slightly tinted shade must land on the grey ramp or a dark
// cube entry, not a saturated one like olive (58) or navy (17).
if n := quant256(RGB(40, 48, 30)); n == 58 || n == 17 || n == 22 {
t.Fatalf("dark tint mapped to saturated %d", n)
}
}
func TestColourDepthsRenderSameGeometry(t *testing.T) {
for _, p := range []colorprofile.Profile{colorprofile.TrueColor, colorprofile.ANSI256, colorprofile.ANSI, colorprofile.ASCII} {
m := newTestModel(t)
m.profile = p
send(m, size(100, 30))
checkFrame(t, m, 100, 30)
}
}