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
+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
}