diff --git a/CHANGELOG.md b/CHANGELOG.md index 53940cb..52091fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ ## Unreleased -- **The terminal's own background:** the panes used color 0 of the terminal's palette ("black"), - which many color themes set to a grey lighter than their background (e.g. Catppuccin), so the - whole program looked washed out and grey text almost disappeared. The terminal is now asked for - its background color at startup and that is used (with 24-bit color and a dark background). The - Log says which background is used and why. +- **The terminal's own colors, light themes too:** the panes used color 0 of the terminal's palette + ("black"), which color themes often set to a grey that differs from their background (e.g. + Catppuccin), so the whole program looked washed out, and on light themes it was a dark grey slab + with white text. The terminal is now asked for its background and text colors at startup. Dark + 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 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. diff --git a/SPECS.md b/SPECS.md index 68cd861..aa3bfa2 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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. -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. diff --git a/internal/ui/app.go b/internal/ui/app.go index 16f24d7..8c405bd 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) } diff --git a/internal/ui/probe_unix.go b/internal/ui/probe_unix.go index c668864..4112a58 100644 --- a/internal/ui/probe_unix.go +++ b/internal/ui/probe_unix.go @@ -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 } diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go index cab75a1..e117a36 100644 --- a/internal/ui/terminal.go +++ b/internal/ui/terminal.go @@ -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 diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go index 95e5f9d..53d1e73 100644 --- a/internal/ui/terminal_test.go +++ b/internal/ui/terminal_test.go @@ -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) + } +}