Use the terminal's own background color

The panes used palette color 0, which color themes such as Catppuccin set
to a grey lighter than their background: everything looked washed out and
grey text almost disappeared (seen in Ghostty on macOS). The startup probe
now also asks for the background color (OSC 11), and with 24-bit color and
a dark background that color is used. The probe always runs at startup, so
a later switch to real images in the Config view never has to ask the
terminal while the screen owns 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:47:27 +02:00
co-authored by Claude Opus 5.5
parent 7e77d56879
commit 0270462fe4
9 changed files with 192 additions and 35 deletions
+6
View File
@@ -100,6 +100,9 @@ type overlay struct {
// New builds the application shell: a tab bar, one page per view and a status bar.
func New(cfg *config.Config) *App {
// Ask the terminal before the screen takes it over and before any widget
// copies the default background.
ownBackground := useTerminalBackground(terminalAnswers(), os.Getenv)
a := &App{
app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true),
@@ -136,6 +139,9 @@ func New(cfg *config.Config) *App {
if a.covers {
a.logs.add(levelInfo, coverNote)
}
if ownBackground {
a.logs.add(levelInfo, "Background: the terminal's own color")
}
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
a.probes = []probe{
+4 -12
View File
@@ -15,7 +15,6 @@ import (
"os/exec"
"runtime"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
@@ -198,17 +197,10 @@ func resolveCovers(setting string, env func(string) string) (blocks coverStyle,
return coverStyleFor(setting, env), false
}
var (
kittyProbeOnce sync.Once
kittyProbeResult bool
)
// kittyGraphicsSupported asks the terminal once (see probeKittyGraphics) and
// remembers the answer. It is a variable so tests can replace it.
var kittyGraphicsSupported = func() bool {
kittyProbeOnce.Do(func() { kittyProbeResult = probeKittyGraphics(300 * time.Millisecond) })
return kittyProbeResult
}
// kittyGraphicsSupported reports whether the terminal confirmed the Kitty
// graphics protocol when it was asked at startup (see probeTerminal). It is a
// variable so tests can replace it.
var kittyGraphicsSupported = func() bool { return terminalAnswers().graphics }
// flatPixelError is the squared RGB distance per pixel below which a cell is
// drawn flat: smooth areas and noise should not turn into patterns.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
)
// TestMain keeps the tests from asking the real terminal about graphics
// support (the probe writes to and reads from /dev/tty).
// support and its colors (the probe writes to and reads from /dev/tty).
func TestMain(m *testing.M) {
kittyGraphicsSupported = func() bool { return false }
terminalAnswers = func() terminalInfo { return terminalInfo{} }
os.Exit(m.Run())
}
+3 -2
View File
@@ -4,5 +4,6 @@ package ui
import "time"
// probeKittyGraphics is not implemented here; the block characters are used.
func probeKittyGraphics(time.Duration) bool { return false }
// probeTerminal is not implemented here: block characters and the default
// colors are used.
func probeTerminal(time.Duration) terminalInfo { return terminalInfo{} }
+16 -18
View File
@@ -12,28 +12,28 @@ import (
"golang.org/x/term"
)
// probeKittyGraphics 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) followed by a Primary Device Attributes request,
// which every terminal answers. If the DA answer arrives without a graphics
// answer before it, the protocol is not supported. Runs before the screen is
// set up, on the controlling terminal.
func probeKittyGraphics(timeout time.Duration) bool {
// 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
// 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.
func probeTerminal(timeout time.Duration) (info terminalInfo) {
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return false
return info
}
defer tty.Close()
fd := int(tty.Fd())
state, err := term.MakeRaw(fd)
if err != nil {
return false
return info
}
defer term.Restore(fd, state)
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c"
const query = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b]11;?\x1b\\" + "\x1b[c"
if _, err := tty.WriteString(query); err != nil {
return false
return info
}
// A terminal that does not know APC sequences may print the query; erase it.
defer tty.WriteString("\r\x1b[2K")
@@ -47,7 +47,7 @@ func probeKittyGraphics(timeout time.Duration) bool {
for {
left := time.Until(deadline)
if left <= 0 {
return false // no answer in time: assume no support
break // no complete answer in time: keep what arrived
}
var fds unix.FdSet
fds.Set(fd)
@@ -57,7 +57,7 @@ func probeKittyGraphics(timeout time.Duration) bool {
continue
}
if err != nil {
return false
break
}
if n == 0 {
continue // timed out; the loop ends above
@@ -67,14 +67,12 @@ func probeKittyGraphics(timeout time.Duration) bool {
continue
}
if err != nil || n <= 0 {
return false
break
}
got = append(got, buf[:n]...)
if bytes.Contains(got, []byte("_Gi=31;OK")) {
return true
}
if da := bytes.Index(got, []byte("\x1b[?")); da >= 0 && bytes.IndexByte(got[da:], 'c') >= 0 {
return false // device attributes answered, graphics query not
break // device attributes answered: everything else came before
}
}
return parseTerminalAnswers(got)
}
+91
View File
@@ -0,0 +1,91 @@
package ui
import (
"bytes"
"image/color"
"strconv"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// terminalInfo is what the terminal said about itself at startup.
type terminalInfo struct {
graphics bool // the Kitty graphics protocol is supported
bg color.RGBA // its background color, if hasBG
hasBG bool
}
var (
terminalOnce sync.Once
terminalResult terminalInfo
)
// terminalAnswers asks the terminal once (see probeTerminal) and remembers the
// answers. The first call must happen before the screen is set up, which owns
// the terminal afterwards; New makes it. It is a variable so tests can replace it.
var terminalAnswers = func() terminalInfo {
terminalOnce.Do(func() { terminalResult = probeTerminal(300 * time.Millisecond) })
return terminalResult
}
// 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
}
}
}
return info
}
// 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
// reports whether it changed anything.
func useTerminalBackground(info terminalInfo, env func(string) string) bool {
if !info.hasBG || !hasTrueColor(env) || luminance(info.bg) > 0.4 {
return false
}
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(int32(info.bg.R), int32(info.bg.G), int32(info.bg.B))
return true
}
// hasTrueColor reports whether the terminal says it draws 24-bit colors.
func hasTrueColor(env func(string) string) bool {
switch strings.ToLower(env("COLORTERM")) {
case "truecolor", "24bit":
return true
}
return strings.HasSuffix(env("TERM"), "-direct")
}
// luminance is the relative brightness of c, from 0 (black) to 1 (white).
func luminance(c color.RGBA) float64 {
return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255
}
+63
View File
@@ -0,0 +1,63 @@
package ui
import (
"image/color"
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func TestParseTerminalAnswers(t *testing.T) {
for _, tc := range []struct {
name string
got string
graphics bool
bg color.RGBA
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},
{"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},
{"garbage", "\x1b]11;rgb:zz/00/00\x1b\\\x1b[?6c", false, color.RGBA{}, false},
{"nothing", "", false, color.RGBA{}, false},
} {
info := parseTerminalAnswers([]byte(tc.got))
if info.graphics != tc.graphics || info.hasBG != tc.hasBG || info.bg != tc.bg {
t.Errorf("%s: %+v", tc.name, info)
}
}
}
func TestUseTerminalBackground(t *testing.T) {
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] } }
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 255}, hasBG: true}
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
truecolor := env(map[string]string{"COLORTERM": "truecolor"})
for _, tc := range []struct {
name string
info terminalInfo
env func(string) string
want bool
}{
{"dark background, true color", dark, truecolor, true},
{"no true color", dark, env(map[string]string{"TERM": "xterm-256color"}), false},
{"light background keeps the default", light, truecolor, false},
{"no answer", terminalInfo{}, truecolor, false},
} {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
if got := useTerminalBackground(tc.info, tc.env); got != tc.want {
t.Errorf("%s: %v", tc.name, got)
}
want := tcell.ColorBlack
if tc.want {
want = tcell.NewRGBColor(30, 30, 46)
}
if tview.Styles.PrimitiveBackgroundColor != want {
t.Errorf("%s: background %v", tc.name, tview.Styles.PrimitiveBackgroundColor)
}
}
}