Fixed dark and light themes, ui.theme auto/dark/light

Colors no longer come from the terminal's palette, whose color 0 many
color themes set to a grey that is not their background. A theme sets
tview's default styles and the color names used in texts, all chosen from
entries 16-255 of the 256-color table so terminals without 24-bit color
show them exactly. "auto" picks light or dark from the background color
the terminal reports. Settable in the Config view, applied at the next
start (widgets copy their colors when created). Replaces using the
terminal's own background and text colors.

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 12:05:00 +02:00
co-authored by Claude Opus 5.5
parent 08d3417b05
commit 820b17842b
20 changed files with 254 additions and 169 deletions
+7 -6
View File
@@ -100,9 +100,10 @@ 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.
backgroundNote := useTerminalBackground(terminalAnswers(), os.Getenv)
// Ask the terminal before the screen takes it over, and set the colors
// before any widget copies them.
th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers())
th.apply()
a := &App{
app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true),
@@ -139,7 +140,7 @@ func New(cfg *config.Config) *App {
if a.covers {
a.logs.add(levelInfo, coverNote)
}
a.logs.add(levelInfo, backgroundNote)
a.logs.add(levelInfo, themeNote)
sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey)
a.sab = sabClient
a.probes = []probe{
@@ -588,11 +589,11 @@ func (a *App) renderTabs() {
for i, v := range a.views {
switch {
case a.narrow && i == a.active:
s += fmt.Sprintf(activeTabTag+"%c %s[-:-] ", v.key, v.name)
s += fmt.Sprintf("[black:white]%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(activeTabTag+" %c %s [-:-] ", v.key, v.name)
s += fmt.Sprintf("[black:white] %c %s [-:-] ", v.key, v.name)
default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name)
}
+1 -1
View File
@@ -371,7 +371,7 @@ func (c *coverView) Draw(screen tcell.Screen) {
}
if c.img == nil {
if c.note != "" {
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, tcell.ColorGray)
tview.Print(screen, "[gray]"+c.note, x, y, w, tview.AlignLeft, named("gray"))
}
return
}
+1 -1
View File
@@ -276,7 +276,7 @@ func (v *downloadsView) renderSummary() {
// header writes the header row; column expand grows to fill the width.
func header(t *tview.Table, expand int, cols ...string) {
for i, c := range cols {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
if i == expand {
cell.SetExpansion(1)
}
+2 -2
View File
@@ -127,7 +127,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
box.SetChecked(want)
return
}
box.SetLabelColor(tcell.ColorGray)
box.SetLabelColor(named("gray"))
box.SetChecked(false)
}
}
@@ -260,7 +260,7 @@ func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
func (h *formHeading) Draw(screen tcell.Screen) {
h.Box.DrawForSubclass(screen, h)
x, y, w, _ := h.GetInnerRect()
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, tcell.ColorYellow)
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, named("yellow"))
}
// formAction is a button inside a form's list of fields, so it can sit between
+2 -2
View File
@@ -66,12 +66,12 @@ var helpSections = []helpSection{
{"t", "retry a failed download (history)"},
{"r", "refresh now"},
}},
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, notifications.", items: []helpItem{
{title: "Config (F6)", note: "Edit the settings of the config file: service addresses and API keys, add-form defaults, refresh interval, posters, theme, notifications.", items: []helpItem{
{"↑↓", "move through all fields, one below the other, in the sections Radarr, Sonarr, SABnzbd and Interface; ↓ after the last field reaches Save and Revert"},
{"Tab", "jump between the fields and Save / Revert"},
{"Space", "toggle a checkbox; press a button"},
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"},
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"},
{"Save, Revert", "(at the bottom) Save writes the file and applies everything at once (the theme at the next start); Revert discards edits"},
}},
{title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
{"↑↓, PgUp, PgDn", "scroll"},
+1 -1
View File
@@ -244,7 +244,7 @@ func newLibraryView(app *App, r *radarr.Client, s *sonarr.Client) *libraryView {
v.cur = kind
v.input.SetLabel("Filter " + kind.String() + ": ")
v.input.SetText(p.text)
p.table.SetBorderColor(tcell.ColorYellow)
p.table.SetBorderColor(named("yellow"))
v.renderDetails()
})
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) })
+3 -3
View File
@@ -23,7 +23,7 @@ func (l level) String() string {
}
func (l level) color() tcell.Color {
return [...]tcell.Color{tcell.ColorWhite, tcell.ColorGreen, tcell.ColorYellow, tcell.ColorRed}[l]
return named([...]string{"white", "green", "yellow", "red"}[l])
}
// how long a toast stays on screen, and how long the end of that time fades.
@@ -142,9 +142,9 @@ func (r *toastRoot) Draw(screen tcell.Screen) {
x := w - width - 1
fg := t.level.color()
if t.expires.Sub(now) < toastFade {
fg = tcell.ColorGray // fade out
fg = named("gray") // fade out
}
style := tcell.StyleDefault.Background(tcell.NewRGBColor(30, 30, 40)).Foreground(fg)
style := tcell.StyleDefault.Background(current.toast).Foreground(fg)
for cx := x; cx < x+width; cx++ {
screen.SetContent(cx, y, ' ', nil, style)
}
+3 -2
View File
@@ -14,7 +14,8 @@ import (
// 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
// does not show) and for its background color (OSC 11), which picks the
// theme. 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 +32,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]10;?\x1b\\" + "\x1b]11;?\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 info
}
+1 -1
View File
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
if c == "Title" {
cell.SetExpansion(1)
}
+33 -4
View File
@@ -24,16 +24,23 @@ type settingsValues struct {
covers bool
coverStyle string
notify, bell bool
theme string
}
var coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"}
var (
coverStyles = []string{"auto", "kitty", "sextant", "quadrant", "cells"}
themes = []string{"auto", "dark", "light"}
)
// settingsFrom reads the settings in use.
func settingsFrom(c *config.Config) settingsValues {
style := c.UI.CoverStyle
style, th := c.UI.CoverStyle, c.UI.Theme
if style == "" {
style = "auto"
}
if th == "" {
th = "auto"
}
return settingsValues{
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
@@ -43,6 +50,7 @@ func settingsFrom(c *config.Config) settingsValues {
refresh: strconv.Itoa(c.UI.RefreshSeconds),
covers: c.UI.ShowCovers(), coverStyle: style,
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell,
theme: th,
}
}
@@ -80,6 +88,10 @@ func applySettings(raw config.Config, v settingsValues, overridden map[string]st
if v.coverStyle == "auto" {
raw.UI.CoverStyle = ""
}
raw.UI.Theme = v.theme
if v.theme == "auto" {
raw.UI.Theme = ""
}
if _, err := raw.Effective(os.Getenv); err != nil {
return raw, err
@@ -97,7 +109,7 @@ func boolSetting(old *bool, value, def bool) *bool {
}
// settingsView edits the configuration file's settings. Everything except
// the cover style of already open panes applies right after saving.
// the theme applies right after saving; the theme at the next start.
type settingsView struct {
app *App
root *tview.Flex
@@ -112,6 +124,7 @@ type settingsView struct {
refresh *tview.InputField
covers *tview.Checkbox
style *tview.DropDown
theme *tview.DropDown
notify *tview.Checkbox
bell *tview.Checkbox
showKeys *tview.Checkbox
@@ -171,6 +184,8 @@ func newSettingsView(app *App) *settingsView {
f.AddFormItem(v.covers)
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
f.AddFormItem(v.style)
v.theme = tview.NewDropDown().SetLabel("Theme (at the next start)").SetOptions(themes, func(string, int) { v.edited() })
f.AddFormItem(v.theme)
v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
f.AddFormItem(v.notify)
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() })
@@ -289,6 +304,11 @@ func (v *settingsView) fill(s settingsValues) {
v.style.SetCurrentOption(i)
}
}
for i, name := range themes {
if name == s.theme {
v.theme.SetCurrentOption(i)
}
}
v.notify.SetChecked(s.notify)
v.bell.SetChecked(s.bell)
v.dirty = false
@@ -302,6 +322,10 @@ func (v *settingsView) read() settingsValues {
if i >= 0 && i < len(coverStyles) {
style = coverStyles[i]
}
th := "auto"
if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) {
th = themes[i]
}
return settingsValues{
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
@@ -311,6 +335,7 @@ func (v *settingsView) read() settingsValues {
refresh: v.refresh.GetText(),
covers: v.covers.IsChecked(), coverStyle: style,
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
theme: th,
}
}
@@ -322,7 +347,7 @@ func (v *settingsView) describe() {
} else {
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n")
}
b.WriteString("Changes take effect when you save; nothing needs a restart.\n")
b.WriteString("Changes take effect when you save; only the theme waits for the next start.\n")
if len(v.overridden) > 0 {
var names []string
for _, env := range v.overridden {
@@ -412,9 +437,13 @@ func (v *settingsView) save() {
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
return
}
themeChanged := effective.UI.Theme != v.app.cfg.UI.Theme
v.app.cfgFile = &raw
v.app.applyConfig(effective)
v.dirty = false
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]")
if themeChanged {
v.report("The new theme is used from the next start.")
}
v.app.Notify(levelSuccess, "Settings saved")
}
+2 -1
View File
@@ -47,12 +47,13 @@ func TestApplySettings(t *testing.T) {
v.notify = false
v.bell = true
v.coverStyle = "quadrant"
v.theme = "light"
got, err = applySettings(raw, v, nil)
if err != nil {
t.Fatal(err)
}
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" {
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" || got.UI.Theme != "light" {
t.Errorf("edits lost: %+v", got)
}
+2 -84
View File
@@ -2,16 +2,11 @@ package ui
import (
"bytes"
"fmt"
"image/color"
"strconv"
"strings"
"sync"
"time"
"github.com/gdamore/tcell/v2"
"github.com/gdamore/tcell/v2/terminfo"
"github.com/rivo/tview"
)
// terminalInfo is what the terminal said about itself at startup.
@@ -19,8 +14,6 @@ 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 (
@@ -39,13 +32,12 @@ 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"))
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
// parseColorAnswer finds the answer to an OSC color query such as 11
// (background): 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:")
@@ -72,80 +64,6 @@ func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) {
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; 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 := 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)"
}
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
// 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 {
switch env("TCELL_TRUECOLOR") {
case "":
case "disable":
return false
default:
return true
}
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).
func luminance(c color.RGBA) float64 {
return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255
+50 -49
View File
@@ -2,7 +2,6 @@ package ui
import (
"image/color"
"strings"
"testing"
"github.com/gdamore/tcell/v2"
@@ -32,64 +31,66 @@ func TestParseTerminalAnswers(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 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] } }
func TestChooseTheme(t *testing.T) {
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 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"})
light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
for _, tc := range []struct {
name string
info terminalInfo
env func(string) string
want bool
setting string
info terminalInfo
want string
}{
{"dark background, true color", dark, truecolor, true},
{"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", light, truecolor, true},
{"no answer", terminalInfo{}, truecolor, false},
{"", light, "light"},
{"auto", dark, "dark"},
{"auto", terminalInfo{}, "dark"},
{"dark", light, "dark"},
{"light", dark, "light"},
} {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack
note := useTerminalBackground(tc.info, tc.env)
if got := strings.Contains(note, "own color"); got != tc.want {
t.Errorf("%s: %s", tc.name, note)
}
want := tcell.ColorBlack
if tc.want {
want = rgb(tc.info.bg)
}
if tview.Styles.PrimitiveBackgroundColor != want {
t.Errorf("%s: background %v", tc.name, tview.Styles.PrimitiveBackgroundColor)
if th, note := chooseTheme(tc.setting, tc.info); th.name != tc.want {
t.Errorf("%q with %+v: %s (%s)", tc.setting, tc.info, th.name, note)
}
}
}
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)
func TestThemeApply(t *testing.T) {
oldStyles, oldCurrent := tview.Styles, current
oldNames := map[string]tcell.Color{}
for k, v := range tcell.ColorNames {
oldNames[k] = v
}
if st.ContrastBackgroundColor == tcell.ColorBlue {
t.Error("input fields should not stay palette blue on a light background")
defer func() {
tview.Styles, current = oldStyles, oldCurrent
for k, v := range oldNames {
tcell.ColorNames[k] = v
}
}()
lightTheme.apply()
if tview.Styles.PrimitiveBackgroundColor != lightTheme.bg || tview.Styles.PrimaryTextColor != lightTheme.text {
t.Error("tview styles not set")
}
if activeTabTag != "[#eff1f5:#4c4f69]" {
t.Errorf("active tab %s", activeTabTag)
// Color names in texts mean the theme's colors.
if named("yellow") != lightTheme.yellow || tcell.GetColor("black") != lightTheme.bg || tcell.GetColor("white") != lightTheme.text {
t.Error("color names not set")
}
// 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)
if current.name != "light" {
t.Error("current theme not set")
}
}
func TestThemeColorsSurvive256Colors(t *testing.T) {
// Every theme color is its own nearest match in the 256-color palette, so
// a terminal without 24-bit color shows it exactly and never picks one of
// the first 16 colors, which color themes change.
palette := make([]tcell.Color, 256)
for i := range palette {
palette[i] = tcell.PaletteColor(i)
}
for _, th := range []theme{darkTheme, lightTheme} {
for _, c := range []tcell.Color{th.bg, th.text, th.border, th.field, th.fieldMuted, th.list, th.pressed, th.toast,
th.gray, th.yellow, th.green, th.red, th.blue, th.purple} {
if got := tcell.FindColor(c, palette); got < tcell.PaletteColor(16) {
t.Errorf("%s theme: %v maps to palette color %d", th.name, c, got-tcell.ColorValid)
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
package ui
import (
"image/color"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
// theme is a complete set of colors, so the program looks the same whatever
// palette the terminal's color theme has (themes often make "black" a grey
// that is not their background, and light themes put dark grey slabs under
// white text). Every color is one of the fixed entries 16-255 of the
// 256-color table, so terminals without 24-bit color show exactly these too
// (the nearest match of an exact color is itself, and the first 16 entries,
// which themes change, never equal them).
type theme struct {
name string
bg, text, border tcell.Color
field, fieldMuted tcell.Color // input fields and buttons; placeholders on them
list tcell.Color // open dropdown lists (drawn with bg-colored text)
pressed tcell.Color // text of a pressed button (on the text color)
toast tcell.Color // background of the popups
// The accent colors, used by name in the texts ("[yellow]...").
gray, yellow, green, red, blue, purple tcell.Color
}
func c256(i int) tcell.Color { return tcell.PaletteColor(i).TrueColor() }
var (
darkTheme = theme{
name: "dark",
bg: c256(232), text: c256(253), border: c256(246),
field: c256(236), fieldMuted: c256(245),
list: c256(110), pressed: c256(25), toast: c256(236),
gray: c256(245), yellow: c256(221), green: c256(113), red: c256(203), blue: c256(75), purple: c256(141),
}
lightTheme = theme{
name: "light",
bg: c256(255), text: c256(234), border: c256(243),
field: c256(252), fieldMuted: c256(245),
list: c256(67), pressed: c256(255), toast: c256(252),
gray: c256(243), yellow: c256(130), green: c256(28), red: c256(160), blue: c256(26), purple: c256(91),
}
)
// current is the theme in use.
var current = darkTheme
// chooseTheme resolves the ui.theme setting: "dark", "light", or "auto" (the
// default), which follows the background color the terminal reported and is
// dark when it did not say. The second value explains the choice for the log.
func chooseTheme(setting string, info terminalInfo) (theme, string) {
switch setting {
case "dark":
return darkTheme, "Theme: dark"
case "light":
return lightTheme, "Theme: light"
}
if !info.hasBG {
return darkTheme, "Theme: dark (auto: the terminal did not report its background color)"
}
hex := hexColor(info.bg)
if luminance(info.bg) > 0.5 {
return lightTheme, "Theme: light (auto: the terminal's background " + hex + " is light)"
}
return darkTheme, "Theme: dark (auto: the terminal's background " + hex + " is dark)"
}
// apply makes t the colors of everything created afterwards: tview's default
// styles (widgets copy them when they are created, so this must run before any
// is) and the color names used in the texts, which tview looks up in tcell's
// table: "[yellow]" means this theme's yellow, "white" its text color and
// "black" its background (the active tab is "[black:white]").
func (t theme) apply() {
current = t
st := &tview.Styles
st.PrimitiveBackgroundColor = t.bg
st.ContrastBackgroundColor = t.field
st.MoreContrastBackgroundColor = t.list
st.BorderColor, st.TitleColor, st.GraphicsColor = t.border, t.text, t.border
st.PrimaryTextColor = t.text
st.SecondaryTextColor = t.yellow
st.TertiaryTextColor = t.green
st.InverseTextColor = t.pressed
st.ContrastSecondaryTextColor = t.fieldMuted
for name, c := range map[string]tcell.Color{
"white": t.text, "black": t.bg,
"gray": t.gray, "grey": t.gray,
"yellow": t.yellow, "green": t.green, "red": t.red,
"blue": t.blue, "purple": t.purple,
} {
tcell.ColorNames[name] = c
}
}
// named returns the theme's color for a name used in the texts.
func named(name string) tcell.Color { return tcell.GetColor(name) }
func hexColor(c color.RGBA) string {
const digits = "0123456789abcdef"
b := []byte{'#', 0, 0, 0, 0, 0, 0}
for i, v := range []uint8{c.R, c.G, c.B} {
b[1+2*i], b[2+2*i] = digits[v>>4], digits[v&15]
}
return string(b)
}