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()
|
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")
|
b.WriteString("\x1b[0")
|
||||||
if st.Attr&Bold != 0 {
|
if st.Attr&Bold != 0 {
|
||||||
b.WriteString(";1")
|
b.WriteString(";1")
|
||||||
@@ -253,10 +262,22 @@ func sgr(b *strings.Builder, st Style, p256 bool) {
|
|||||||
if c == 0 {
|
if c == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p256 {
|
switch depth {
|
||||||
|
case depth256:
|
||||||
b.WriteString(";" + base + ";5;")
|
b.WriteString(";" + base + ";5;")
|
||||||
b.WriteString(strconv.Itoa(int(quant256(c))))
|
b.WriteString(strconv.Itoa(int(quant256(c))))
|
||||||
return
|
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(";" + base + ";2;")
|
||||||
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
|
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
|
||||||
@@ -270,14 +291,14 @@ func sgr(b *strings.Builder, st Style, p256 bool) {
|
|||||||
b.WriteByte('m')
|
b.WriteByte('m')
|
||||||
}
|
}
|
||||||
|
|
||||||
// String serialises the canvas as lines of text with SGR styling. Colours
|
// String serialises the canvas as lines of text with SGR styling and
|
||||||
// are emitted as 24-bit, or as 256-colour indices from our own quantizer
|
// 24-bit colours.
|
||||||
// when p256 is set; Bubble Tea downsamples further (16 colours, none) to
|
func (cv *Canvas) String() string { return cv.Render(depthTrue) }
|
||||||
// the terminal's profile.
|
|
||||||
func (cv *Canvas) String() string { return cv.Render(false) }
|
|
||||||
|
|
||||||
// Render serialises the canvas; see String.
|
// Render serialises the canvas. For 256 and 16 colours it quantizes with
|
||||||
func (cv *Canvas) Render(p256 bool) string {
|
// 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
|
var b strings.Builder
|
||||||
b.Grow(cv.W * cv.H * 4)
|
b.Grow(cv.W * cv.H * 4)
|
||||||
for y := 0; y < cv.H; y++ {
|
for y := 0; y < cv.H; y++ {
|
||||||
@@ -292,7 +313,7 @@ func (cv *Canvas) Render(p256 bool) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if first || c.st != cur {
|
if first || c.st != cur {
|
||||||
sgr(&b, c.st, p256)
|
sgr(&b, c.st, depth)
|
||||||
cur, first = c.st, false
|
cur, first = c.st, false
|
||||||
}
|
}
|
||||||
b.WriteString(c.s)
|
b.WriteString(c.s)
|
||||||
|
|||||||
@@ -43,3 +43,54 @@ func quant256(c Color) uint8 {
|
|||||||
q256Cache.Store(c, uint8(best))
|
q256Cache.Store(c, uint8(best))
|
||||||
return 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)
|
g := computeGeometry(m.w, m.h)
|
||||||
if g.class == tooSmall {
|
if g.class == tooSmall {
|
||||||
m.renderTooSmall(cv)
|
m.renderTooSmall(cv)
|
||||||
return cv.Render(m.profile == colorprofile.ANSI256)
|
return cv.Render(m.depth())
|
||||||
}
|
}
|
||||||
m.tree.RLock()
|
m.tree.RLock()
|
||||||
defer m.tree.RUnlock()
|
defer m.tree.RUnlock()
|
||||||
@@ -100,7 +100,17 @@ func (m *Model) render() string {
|
|||||||
if len(m.modals) > 0 {
|
if len(m.modals) > 0 {
|
||||||
m.paintModal(cv)
|
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) {
|
func (m *Model) renderTooSmall(cv *Canvas) {
|
||||||
|
|||||||
@@ -454,3 +454,29 @@ func TestMouse(t *testing.T) {
|
|||||||
t.Fatal("click outside modal should close it")
|
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