diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go index 7d1e808..d153821 100644 --- a/internal/tui/canvas.go +++ b/internal/tui/canvas.go @@ -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) diff --git a/internal/tui/color256.go b/internal/tui/color256.go index a7f46de..88f9091 100644 --- a/internal/tui/color256.go +++ b/internal/tui/color256.go @@ -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 +} diff --git a/internal/tui/render.go b/internal/tui/render.go index cfcbb1e..bd2174f 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -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) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 8a0f3ba..4eb2ec1 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -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) + } +}