Light terminal themes: use the terminal's text color too

On a light background (Catppuccin Latte in Ghostty on macOS) the panes
kept palette color 0, a dark grey slab with white text. The probe now also
asks for the text color (OSC 10). Light backgrounds are used as well, with
text, borders and titles in the terminal's text color, input fields and
buttons in a light shade of it, and the active tab drawn in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
This commit is contained in:
2026-10-21 11:59:44 +02:00
co-authored by Claude Opus 5.5
parent d2e868293f
commit 08d3417b05
6 changed files with 114 additions and 50 deletions
+2 -2
View File
@@ -588,11 +588,11 @@ func (a *App) renderTabs() {
for i, v := range a.views {
switch {
case a.narrow && i == a.active:
s += fmt.Sprintf("[black:white]%c %s[-:-] ", v.key, v.name)
s += fmt.Sprintf(activeTabTag+"%c %s[-:-] ", v.key, v.name)
case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active:
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
s += fmt.Sprintf(activeTabTag+" %c %s [-:-] ", v.key, v.name)
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
}
+4 -4
View File
@@ -12,9 +12,9 @@ import (
"golang.org/x/term"
)
// probeTerminal asks the terminal two things: whether it understands the Kitty
// graphics protocol (a tiny query image, which a capable terminal answers with
// "OK" and does not show) and its background color (OSC 11). A Primary Device
// probeTerminal asks the terminal whether it understands the Kitty graphics
// protocol (a tiny query image, which a capable terminal answers with "OK" and
// does not show) and for its text and background colors (OSC 10 and 11). A Primary Device
// Attributes request follows, which every terminal answers, and answers come
// in order: once the DA answer is in, whatever was not answered before it is
// not supported. Runs before the screen is set up, on the controlling terminal.
@@ -31,7 +31,7 @@ func probeTerminal(timeout time.Duration) (info terminalInfo) {
}
defer term.Restore(fd, state)
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b]11;?\x1b\\" + "\x1b[c"
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b]10;?\x1b\\" + "\x1b]11;?\x1b\\" + "\x1b[c"
if _, err := tty.WriteString(query); err != nil {
return info
}
+73 -34
View File
@@ -19,6 +19,8 @@ type terminalInfo struct {
graphics bool // the Kitty graphics protocol is supported
bg color.RGBA // its background color, if hasBG
hasBG bool
fg color.RGBA // its text color, if hasFG
hasFG bool
}
var (
@@ -37,52 +39,89 @@ var terminalAnswers = func() terminalInfo {
// parseTerminalAnswers reads the answers to probeTerminal's questions.
func parseTerminalAnswers(got []byte) (info terminalInfo) {
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
// OSC 11 answer: ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
// component has 1 to 4 hex digits.
if i := bytes.Index(got, []byte("\x1b]11;rgb:")); i >= 0 {
rest := got[i+len("\x1b]11;rgb:"):]
if end := bytes.IndexAny(rest, "\x1b\a"); end >= 0 {
rest = rest[:end]
}
if parts := strings.Split(string(rest), "/"); len(parts) == 3 {
var c [3]uint8
ok := true
for j, p := range parts {
v, err := strconv.ParseUint(p, 16, 16)
if err != nil || len(p) < 1 || len(p) > 4 {
ok = false
break
}
c[j] = uint8(v * 255 / (1<<(4*len(p)) - 1))
}
if ok {
info.bg, info.hasBG = color.RGBA{c[0], c[1], c[2], 255}, true
}
}
}
info.fg, info.hasFG = parseColorAnswer(got, "10")
info.bg, info.hasBG = parseColorAnswer(got, "11")
return info
}
// parseColorAnswer finds the answer to an OSC 10 (text) or 11 (background)
// color query: ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
// component has 1 to 4 hex digits.
func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) {
prefix := []byte("\x1b]" + osc + ";rgb:")
i := bytes.Index(got, prefix)
if i < 0 {
return color.RGBA{}, false
}
rest := got[i+len(prefix):]
if end := bytes.IndexAny(rest, "\x1b\a"); end >= 0 {
rest = rest[:end]
}
parts := strings.Split(string(rest), "/")
if len(parts) != 3 {
return color.RGBA{}, false
}
var c [3]uint8
for j, p := range parts {
v, err := strconv.ParseUint(p, 16, 16)
if err != nil || len(p) < 1 || len(p) > 4 {
return color.RGBA{}, false
}
c[j] = uint8(v * 255 / (1<<(4*len(p)) - 1))
}
return color.RGBA{c[0], c[1], c[2], 255}, true
}
// activeTabTag colors the name of the open view in the tab bar.
var activeTabTag = "[black:white]"
// useTerminalBackground makes the terminal's own background color the
// background of every pane, instead of color 0 of the palette ("black"), which
// color themes often set to a grey that differs from their background (and
// on which grey text almost disappears). Only for dark backgrounds: the text
// colors are made for those. Needs true color, else the exact color cannot be
// drawn. Must run before any widget is created, they copy the style. It
// returns a line for the log that says what was decided and why.
// on which grey text almost disappears; in light themes color 0 is a dark
// grey). On a light background the text, borders and fields switch to the
// terminal's text color and shades between the two, since tview's defaults
// (white text, blue fields) are made for dark ones. The accent colors (yellow,
// green, red, grey) stay palette colors, which a theme picks to suit its
// background. Needs true color, else the exact colors cannot be drawn. Must
// run before any widget is created, they copy the styles. It returns a line
// for the log that says what was decided and why.
func useTerminalBackground(info terminalInfo, env func(string) string) string {
if !info.hasBG {
return "Background: palette color 0 (the terminal did not report its background color)"
}
hex := fmt.Sprintf("#%02x%02x%02x", info.bg.R, info.bg.G, info.bg.B)
switch {
case !hasTrueColor(env):
hex := hexColor(info.bg)
if !hasTrueColor(env) {
return "Background: palette color 0 (the terminal's " + hex + " needs 24-bit color, which " + env("TERM") + " does not have; COLORTERM=truecolor enables it)"
case luminance(info.bg) > 0.4:
return "Background: palette color 0 (the terminal's " + hex + " is light; the text colors are made for dark backgrounds)"
}
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(int32(info.bg.R), int32(info.bg.G), int32(info.bg.B))
return "Background: the terminal's own color " + hex
st := &tview.Styles
st.PrimitiveBackgroundColor = rgb(info.bg)
if luminance(info.bg) <= 0.5 {
return "Background: the terminal's own color " + hex
}
fg := color.RGBA{0, 0, 0, 255}
if info.hasFG && luminance(info.fg) < luminance(info.bg) {
fg = info.fg
}
st.PrimaryTextColor, st.BorderColor, st.TitleColor, st.GraphicsColor = rgb(fg), rgb(fg), rgb(fg), rgb(fg)
st.ContrastBackgroundColor = rgb(mix(info.bg, fg, 0.12)) // input fields, buttons
st.ContrastSecondaryTextColor = rgb(mix(info.bg, fg, 0.55)) // placeholders, disabled buttons
st.InverseTextColor = rgb(info.bg) // a pressed button: background on text color
activeTabTag = "[" + hexColor(info.bg) + ":" + hexColor(fg) + "]"
return "Background: the terminal's own color " + hex + ", light, with its text color " + hexColor(fg)
}
func rgb(c color.RGBA) tcell.Color {
return tcell.NewRGBColor(int32(c.R), int32(c.G), int32(c.B))
}
func hexColor(c color.RGBA) string { return fmt.Sprintf("#%02x%02x%02x", c.R, c.G, c.B) }
// mix returns the color a fraction t of the way from a to b.
func mix(a, b color.RGBA, t float64) color.RGBA {
m := func(x, y uint8) uint8 { return uint8(float64(x) + (float64(y)-float64(x))*t + 0.5) }
return color.RGBA{m(a.R, b.R), m(a.G, b.G), m(a.B, b.B), 255}
}
// hasTrueColor reports whether tcell will draw 24-bit colors, by the same
+27 -4
View File
@@ -18,6 +18,7 @@ func TestParseTerminalAnswers(t *testing.T) {
hasBG bool
}{
{"ghostty", "\x1b_Gi=31;OK\x1b\\\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\\x1b[?62;22c", true, color.RGBA{30, 30, 46, 255}, true},
{"text color too", "\x1b]10;rgb:4c4c/4f4f/6969\x1b\\\x1b]11;rgb:efef/f1f1/f5f5\x1b\\\x1b[?62c", false, color.RGBA{239, 241, 245, 255}, true},
{"bel and two digits", "\x1b]11;rgb:ff/80/00\a\x1b[?1;2c", false, color.RGBA{255, 128, 0, 255}, true},
{"one digit", "\x1b]11;rgb:f/0/8\x1b\\\x1b[?6c", false, color.RGBA{255, 0, 136, 255}, true},
{"only device attributes", "\x1b[?1;2c", false, color.RGBA{}, false},
@@ -37,10 +38,10 @@ func TestUseTerminalBackground(t *testing.T) {
// a terminal nothing else looks up).
t.Setenv("COLORTERM", "")
t.Setenv("TCELL_TRUECOLOR", "")
defer func(old tcell.Color) { tview.Styles.PrimitiveBackgroundColor = old }(tview.Styles.PrimitiveBackgroundColor)
defer func(old tview.Theme, tab string) { tview.Styles, activeTabTag = old, tab }(tview.Styles, activeTabTag)
env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 255}, hasBG: true}
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true, fg: color.RGBA{76, 79, 105, 255}, hasFG: true}
truecolor := env(map[string]string{"COLORTERM": "truecolor"})
for _, tc := range []struct {
@@ -53,7 +54,7 @@ func TestUseTerminalBackground(t *testing.T) {
{"no true color", dark, env(map[string]string{"TERM": "vt100"}), false},
{"ghostty without COLORTERM", dark, env(map[string]string{"TERM": "xterm-ghostty"}), true},
{"true color disabled for tcell", dark, env(map[string]string{"TERM": "xterm-ghostty", "TCELL_TRUECOLOR": "disable"}), false},
{"light background keeps the default", light, truecolor, false},
{"light background", light, truecolor, true},
{"no answer", terminalInfo{}, truecolor, false},
} {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
@@ -63,10 +64,32 @@ func TestUseTerminalBackground(t *testing.T) {
}
want := tcell.ColorBlack
if tc.want {
want = tcell.NewRGBColor(30, 30, 46)
want = rgb(tc.info.bg)
}
if tview.Styles.PrimitiveBackgroundColor != want {
t.Errorf("%s: background %v", tc.name, tview.Styles.PrimitiveBackgroundColor)
}
}
}
func TestLightBackgroundUsesTheTextColor(t *testing.T) {
defer func(old tview.Theme, tab string) { tview.Styles, activeTabTag = old, tab }(tview.Styles, activeTabTag)
latte := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true, fg: color.RGBA{76, 79, 105, 255}, hasFG: true}
useTerminalBackground(latte, func(k string) string { return map[string]string{"COLORTERM": "truecolor"}[k] })
st := tview.Styles
if st.PrimaryTextColor != tcell.NewRGBColor(76, 79, 105) || st.BorderColor != st.PrimaryTextColor {
t.Errorf("text %v, borders %v", st.PrimaryTextColor, st.BorderColor)
}
if st.ContrastBackgroundColor == tcell.ColorBlue {
t.Error("input fields should not stay palette blue on a light background")
}
if activeTabTag != "[#eff1f5:#4c4f69]" {
t.Errorf("active tab %s", activeTabTag)
}
// Without a text color answer the text is black.
tview.Styles = st
useTerminalBackground(terminalInfo{bg: latte.bg, hasBG: true}, func(k string) string { return map[string]string{"COLORTERM": "truecolor"}[k] })
if tview.Styles.PrimaryTextColor != tcell.NewRGBColor(0, 0, 0) {
t.Errorf("fallback text %v", tview.Styles.PrimitiveBackgroundColor)
}
}