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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user