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:
+7
-5
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- **The terminal's own background:** the panes used color 0 of the terminal's palette ("black"),
|
- **The terminal's own colors, light themes too:** the panes used color 0 of the terminal's palette
|
||||||
which many color themes set to a grey lighter than their background (e.g. Catppuccin), so the
|
("black"), which color themes often set to a grey that differs from their background (e.g.
|
||||||
whole program looked washed out and grey text almost disappeared. The terminal is now asked for
|
Catppuccin), so the whole program looked washed out, and on light themes it was a dark grey slab
|
||||||
its background color at startup and that is used (with 24-bit color and a dark background). The
|
with white text. The terminal is now asked for its background and text colors at startup. Dark
|
||||||
Log says which background is used and why.
|
backgrounds are used as they are; on light ones the text, borders, input fields and the active
|
||||||
|
tab follow the terminal's text color as well. Needs 24-bit color; the Log says which colors are
|
||||||
|
used and why.
|
||||||
- **Smooth scrolling on huge terminals:** while an arrow key is held, the poster waits until the
|
- **Smooth scrolling on huge terminals:** while an arrow key is held, the poster waits until the
|
||||||
selection rests (120 ms). Every poster rewrites all of its cells, which on a terminal zoomed far
|
selection rests (120 ms). Every poster rewrites all of its cells, which on a terminal zoomed far
|
||||||
out was more output per step than the terminal could draw, so moving through a list lagged.
|
out was more output per step than the terminal could draw, so moving through a list lagged.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Layout sketch (Library view; the other views follow the same frame):
|
|||||||
|
|
||||||
Popups for action feedback appear in the bottom right and fade away; the Log view (F7) keeps the history.
|
Popups for action feedback appear in the bottom right and fade away; the Log view (F7) keeps the history.
|
||||||
|
|
||||||
Colors: the panes' background is the terminal's own background color (asked with OSC 11 at startup) when the terminal has 24-bit color (by tcell's rules: `TCELL_TRUECOLOR`, `COLORTERM`, or a terminal description with direct color such as `xterm-ghostty`, which Ghostty on macOS uses without setting `COLORTERM`) and a dark background, and the Log says which and why; otherwise tview's default, palette color 0. Color themes often make color 0 a grey lighter than their background (Catppuccin: `#45475a` on `#1e1e2e`), on which grey text almost disappears. Light backgrounds keep color 0 because the text colors are made for dark ones.
|
Colors: the terminal is asked for its text and background colors at startup (OSC 10 and 11, in the same round trip as the graphics query). With 24-bit color (by tcell's rules: `TCELL_TRUECOLOR`, `COLORTERM`, or a terminal description with direct color such as `xterm-ghostty`, which Ghostty on macOS uses without setting `COLORTERM`) the panes' background is the terminal's own; otherwise tview's default, palette color 0, which color themes often make a grey that differs from their background (Catppuccin Mocha: `#45475a` on `#1e1e2e`; Latte: `#5c5f77` on `#eff1f5`). On a light background (luminance above 0.5) text, borders and titles use the terminal's text color (black if it did not say), input fields and buttons a 12% shade of it, placeholders 55%, and the active tab is drawn in the text color; tview's own defaults (white text, blue fields) are made for dark backgrounds. Accent colors (yellow, green, red, grey) stay palette colors, which a theme picks to suit its background. The Log says which colors are used and why.
|
||||||
|
|
||||||
tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive.
|
tview primitives: `Pages` (views and stacked overlays), `Flex` (layout), `InputField` (search), `List`/`Table` (results, queues, library), `TextView` (details, log), `Form` (add dialog), `Modal` (confirmations). Popups are drawn by a wrapper around the root primitive.
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -588,11 +588,11 @@ func (a *App) renderTabs() {
|
|||||||
for i, v := range a.views {
|
for i, v := range a.views {
|
||||||
switch {
|
switch {
|
||||||
case a.narrow && i == a.active:
|
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:
|
case a.narrow:
|
||||||
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
|
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
|
||||||
case i == a.active:
|
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:
|
default:
|
||||||
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
"golang.org/x/term"
|
"golang.org/x/term"
|
||||||
)
|
)
|
||||||
|
|
||||||
// probeTerminal asks the terminal two things: whether it understands the Kitty
|
// probeTerminal asks the terminal whether it understands the Kitty graphics
|
||||||
// graphics protocol (a tiny query image, which a capable terminal answers with
|
// protocol (a tiny query image, which a capable terminal answers with "OK" and
|
||||||
// "OK" and does not show) and its background color (OSC 11). A Primary Device
|
// 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
|
// 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
|
// 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.
|
// 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)
|
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 {
|
if _, err := tty.WriteString(query); err != nil {
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-34
@@ -19,6 +19,8 @@ type terminalInfo struct {
|
|||||||
graphics bool // the Kitty graphics protocol is supported
|
graphics bool // the Kitty graphics protocol is supported
|
||||||
bg color.RGBA // its background color, if hasBG
|
bg color.RGBA // its background color, if hasBG
|
||||||
hasBG bool
|
hasBG bool
|
||||||
|
fg color.RGBA // its text color, if hasFG
|
||||||
|
hasFG bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -37,52 +39,89 @@ var terminalAnswers = func() terminalInfo {
|
|||||||
// parseTerminalAnswers reads the answers to probeTerminal's questions.
|
// parseTerminalAnswers reads the answers to probeTerminal's questions.
|
||||||
func parseTerminalAnswers(got []byte) (info terminalInfo) {
|
func parseTerminalAnswers(got []byte) (info terminalInfo) {
|
||||||
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
|
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
|
||||||
// OSC 11 answer: ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
|
info.fg, info.hasFG = parseColorAnswer(got, "10")
|
||||||
// component has 1 to 4 hex digits.
|
info.bg, info.hasBG = parseColorAnswer(got, "11")
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return info
|
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
|
// useTerminalBackground makes the terminal's own background color the
|
||||||
// background of every pane, instead of color 0 of the palette ("black"), which
|
// 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
|
// 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
|
// on which grey text almost disappears; in light themes color 0 is a dark
|
||||||
// colors are made for those. Needs true color, else the exact color cannot be
|
// grey). On a light background the text, borders and fields switch to the
|
||||||
// drawn. Must run before any widget is created, they copy the style. It
|
// terminal's text color and shades between the two, since tview's defaults
|
||||||
// returns a line for the log that says what was decided and why.
|
// (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 {
|
func useTerminalBackground(info terminalInfo, env func(string) string) string {
|
||||||
if !info.hasBG {
|
if !info.hasBG {
|
||||||
return "Background: palette color 0 (the terminal did not report its background color)"
|
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)
|
hex := hexColor(info.bg)
|
||||||
switch {
|
if !hasTrueColor(env) {
|
||||||
case !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)"
|
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))
|
st := &tview.Styles
|
||||||
return "Background: the terminal's own color " + hex
|
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
|
// hasTrueColor reports whether tcell will draw 24-bit colors, by the same
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func TestParseTerminalAnswers(t *testing.T) {
|
|||||||
hasBG bool
|
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},
|
{"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},
|
{"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},
|
{"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},
|
{"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).
|
// a terminal nothing else looks up).
|
||||||
t.Setenv("COLORTERM", "")
|
t.Setenv("COLORTERM", "")
|
||||||
t.Setenv("TCELL_TRUECOLOR", "")
|
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] } }
|
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}
|
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"})
|
truecolor := env(map[string]string{"COLORTERM": "truecolor"})
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
@@ -53,7 +54,7 @@ func TestUseTerminalBackground(t *testing.T) {
|
|||||||
{"no true color", dark, env(map[string]string{"TERM": "vt100"}), false},
|
{"no true color", dark, env(map[string]string{"TERM": "vt100"}), false},
|
||||||
{"ghostty without COLORTERM", dark, env(map[string]string{"TERM": "xterm-ghostty"}), true},
|
{"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},
|
{"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},
|
{"no answer", terminalInfo{}, truecolor, false},
|
||||||
} {
|
} {
|
||||||
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
|
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
|
||||||
@@ -63,10 +64,32 @@ func TestUseTerminalBackground(t *testing.T) {
|
|||||||
}
|
}
|
||||||
want := tcell.ColorBlack
|
want := tcell.ColorBlack
|
||||||
if tc.want {
|
if tc.want {
|
||||||
want = tcell.NewRGBColor(30, 30, 46)
|
want = rgb(tc.info.bg)
|
||||||
}
|
}
|
||||||
if tview.Styles.PrimitiveBackgroundColor != want {
|
if tview.Styles.PrimitiveBackgroundColor != want {
|
||||||
t.Errorf("%s: background %v", tc.name, tview.Styles.PrimitiveBackgroundColor)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user