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:
+31
-10
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user