Terminal background: decide 24-bit color like tcell, and log why

The check only looked at COLORTERM, but tcell also draws 24-bit color for
terminals whose description has it (xterm-ghostty, xterm-kitty), so in
Ghostty on macOS without COLORTERM the background was never used. Use
tcell's rules, and always log which background is used and why.

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:53:47 +02:00
co-authored by Claude Opus 5.5
parent 0270462fe4
commit d2e868293f
5 changed files with 48 additions and 18 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
- **The terminal's own background:** the panes used color 0 of the terminal's palette ("black"), - **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 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 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). 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.
- **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.
+1 -1
View File
@@ -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 and a dark background; 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 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.
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 -4
View File
@@ -102,7 +102,7 @@ type overlay struct {
func New(cfg *config.Config) *App { func New(cfg *config.Config) *App {
// Ask the terminal before the screen takes it over and before any widget // Ask the terminal before the screen takes it over and before any widget
// copies the default background. // copies the default background.
ownBackground := useTerminalBackground(terminalAnswers(), os.Getenv) backgroundNote := useTerminalBackground(terminalAnswers(), os.Getenv)
a := &App{ a := &App{
app: tview.NewApplication(), app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true), tabs: tview.NewTextView().SetDynamicColors(true),
@@ -139,9 +139,7 @@ func New(cfg *config.Config) *App {
if a.covers { if a.covers {
a.logs.add(levelInfo, coverNote) a.logs.add(levelInfo, coverNote)
} }
if ownBackground { a.logs.add(levelInfo, backgroundNote)
a.logs.add(levelInfo, "Background: the terminal's own color")
}
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey) sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient a.sab = sabClient
a.probes = []probe{ a.probes = []probe{
+31 -9
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"bytes" "bytes"
"fmt"
"image/color" "image/color"
"strconv" "strconv"
"strings" "strings"
@@ -9,6 +10,7 @@ import (
"time" "time"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/terminfo"
"github.com/rivo/tview" "github.com/rivo/tview"
) )
@@ -67,22 +69,42 @@ func parseTerminalAnswers(got []byte) (info terminalInfo) {
// on which grey text almost disappears). Only for dark backgrounds: the text // 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 // 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 // drawn. Must run before any widget is created, they copy the style. It
// reports whether it changed anything. // returns a line for the log that says what was decided and why.
func useTerminalBackground(info terminalInfo, env func(string) string) bool { func useTerminalBackground(info terminalInfo, env func(string) string) string {
if !info.hasBG || !hasTrueColor(env) || luminance(info.bg) > 0.4 { if !info.hasBG {
return false 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):
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)) tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(int32(info.bg.R), int32(info.bg.G), int32(info.bg.B))
return true return "Background: the terminal's own color " + hex
} }
// hasTrueColor reports whether the terminal says it draws 24-bit colors. // hasTrueColor reports whether tcell will draw 24-bit colors, by the same
// rules: TCELL_TRUECOLOR, COLORTERM, or a terminal description with direct
// color (e.g. xterm-ghostty and xterm-kitty, also without COLORTERM).
func hasTrueColor(env func(string) string) bool { func hasTrueColor(env func(string) string) bool {
switch strings.ToLower(env("COLORTERM")) { switch env("TCELL_TRUECOLOR") {
case "truecolor", "24bit": case "":
case "disable":
return false
default:
return true return true
} }
return strings.HasSuffix(env("TERM"), "-direct") switch strings.ToLower(env("COLORTERM")) {
case "truecolor", "24bit", "24-bit":
return true
}
if strings.HasSuffix(env("TERM"), "-direct") {
return true
}
ti, err := terminfo.LookupTerminfo(env("TERM"))
return err == nil && (ti.TrueColor || ti.SetFgBgRGB != "")
} }
// luminance is the relative brightness of c, from 0 (black) to 1 (white). // luminance is the relative brightness of c, from 0 (black) to 1 (white).
+12 -3
View File
@@ -2,6 +2,7 @@ package ui
import ( import (
"image/color" "image/color"
"strings"
"testing" "testing"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -31,6 +32,11 @@ func TestParseTerminalAnswers(t *testing.T) {
} }
func TestUseTerminalBackground(t *testing.T) { func TestUseTerminalBackground(t *testing.T) {
// tcell's terminal database also reads these from the real environment
// (and keeps what it added to an entry because of them, so the test uses
// 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 tcell.Color) { tview.Styles.PrimitiveBackgroundColor = old }(tview.Styles.PrimitiveBackgroundColor)
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}
@@ -44,13 +50,16 @@ func TestUseTerminalBackground(t *testing.T) {
want bool want bool
}{ }{
{"dark background, true color", dark, truecolor, true}, {"dark background, true color", dark, truecolor, true},
{"no true color", dark, env(map[string]string{"TERM": "xterm-256color"}), false}, {"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 keeps the default", light, truecolor, false},
{"no answer", terminalInfo{}, truecolor, false}, {"no answer", terminalInfo{}, truecolor, false},
} { } {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
if got := useTerminalBackground(tc.info, tc.env); got != tc.want { note := useTerminalBackground(tc.info, tc.env)
t.Errorf("%s: %v", tc.name, got) if got := strings.Contains(note, "own color"); got != tc.want {
t.Errorf("%s: %s", tc.name, note)
} }
want := tcell.ColorBlack want := tcell.ColorBlack
if tc.want { if tc.want {