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
+6 -7
View File
@@ -2,13 +2,12 @@
## Unreleased ## Unreleased
- **The terminal's own colors, light themes too:** the panes used color 0 of the terminal's palette - **Dark and light themes:** fixed colors that look the same in every terminal (`ui.theme`: `auto`,
("black"), which color themes often set to a grey that differs from their background (e.g. `dark` or `light`, also in the Config view; applies at the next start). Before, the panes used
Catppuccin), so the whole program looked washed out, and on light themes it was a dark grey slab color 0 of the terminal's palette, which color themes often set to a grey that is not their
with white text. The terminal is now asked for its background and text colors at startup. Dark background (e.g. Catppuccin), so the program looked washed out, and on light themes it was a dark
backgrounds are used as they are; on light ones the text, borders, input fields and the active grey slab with white text. `auto` asks the terminal for its background color and picks light or
tab follow the terminal's text color as well. Needs 24-bit color; the Log says which colors are dark to match; the Log says which and why.
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.
+7 -3
View File
@@ -28,15 +28,19 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video
- **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete, - **Downloads**: SABnzbd queue and history plus the Radarr/Sonarr queues. Pause/resume, delete,
retry failed downloads, remove from the arr queue and blocklist. retry failed downloads, remove from the arr queue and blocklist.
- **Config (F6)**: edit everything in the config file from inside the program, in one list you move - **Config (F6)**: edit everything in the config file from inside the program, in one list you move
through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, notifications. *Test* through with the arrow keys: service addresses and API keys, the add-form defaults, refresh interval, posters, theme, notifications. *Test*
checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a
new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*` new file) and applies the changes at once (the theme at the next start). Values that come from `ARR_TUI_*`
environment variables are greyed out and are never written to the file. environment variables are greyed out and are never written to the file.
- **Log (F7)**: actions report through popups that fade away; the log keeps the history. - **Log (F7)**: actions report through popups that fade away; the log keeps the history.
- **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails, - **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails,
whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished whichever view is open. SABnzbd's history is checked every 15 seconds; downloads that finished
before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off, before arr-tui started are not announced. `"ui": {"notify_downloads": false}` turns it off,
`"bell": true` also rings the terminal bell. `"bell": true` also rings the terminal bell.
- **Themes**: fixed dark and light color sets that look the same in every terminal, whatever its
color theme. `"ui": {"theme": ...}` is `auto` (default: light when the terminal reports a light
background, else dark), `dark` or `light`; it can also be set in the Config view and applies at the
next start.
- Connection indicators for all three services in the top right; `F9` shows every key. - Connection indicators for all three services in the top right; `F9` shows every key.
Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are Requires Radarr/Sonarr v3+ API (Radarr 5/6, Sonarr 4) and SABnzbd 4+. Only Usenet releases are
@@ -61,7 +65,7 @@ Settings → General.
"radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" }, "radarr": { "url": "http://localhost:7878", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/movies" },
"sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" }, "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" },
"sabnzbd": { "url": "http://localhost:8080", "apikey": "..." }, "sabnzbd": { "url": "http://localhost:8080", "apikey": "..." },
"ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "notify_downloads": true, "bell": false } "ui": { "refresh_seconds": 3, "covers": true, "cover_style": "auto", "theme": "auto", "notify_downloads": true, "bell": false }
} }
``` ```
+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 terminal is asked for its text and background colors at startup (OSC 10 and 11, in the same round trip as the graphics query). With 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`) the panes' background is the terminal's own; otherwise tview's default, palette color 0, which color themes often make a grey that differs from their background (Catppuccin Mocha: `#45475a` on `#1e1e2e`; Latte: `#5c5f77` on `#eff1f5`). On a light background (luminance above 0.5) text, borders and titles use the terminal's text color (black if it did not say), input fields and buttons a 12% shade of it, placeholders 55%, and the active tab is drawn in the text color; tview's own defaults (white text, blue fields) are made for dark backgrounds. Accent colors (yellow, green, red, grey) stay palette colors, which a theme picks to suit its background. The Log says which colors are used and why. Colors: two fixed themes, dark (near-black background, like k9s) and light, chosen with `ui.theme`: `auto` (default) asks the terminal for its background color at startup (OSC 11, in the same round trip as the graphics query) and takes light for a background with luminance above 0.5, dark otherwise or without an answer. The terminal's palette is not used: color themes often make color 0 ("black", tview's default background) a grey that differs from their background (Catppuccin Mocha: `#45475a` on `#1e1e2e`; Latte: `#5c5f77` on `#eff1f5`), which looked washed out, and on light themes dark grey slabs carried white text. A theme sets tview's default styles (before any widget is created; widgets copy them, so a changed theme applies at the next start) and overrides the color names used in texts in tcell's table (`[yellow]` is the theme's yellow, `white` its text and `black` its background, so the active tab `[black:white]` follows too). Every theme color is an entry 16-255 of the 256-color table, so terminals without 24-bit color show it exactly, and never one of the first 16 entries that color themes change. The Log says which theme is used and why.
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 -1
View File
@@ -20,6 +20,7 @@
"covers": true, "covers": true,
"notify_downloads": true, "notify_downloads": true,
"bell": false, "bell": false,
"cover_style": "auto" "cover_style": "auto",
"theme": "auto"
} }
} }
+8
View File
@@ -48,6 +48,9 @@ type UI struct {
// known to support it, else cells in macOS Terminal, sextant on terminals known to draw them or when // known to support it, else cells in macOS Terminal, sextant on terminals known to draw them or when
// fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty". // fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty".
CoverStyle string `json:"cover_style,omitempty"` CoverStyle string `json:"cover_style,omitempty"`
// Theme selects the colors: "dark", "light" or "auto" (default: light when
// the terminal reports a light background, else dark).
Theme string `json:"theme,omitempty"`
} }
// ShouldNotifyDownloads reports whether finished downloads are announced. // ShouldNotifyDownloads reports whether finished downloads are announced.
@@ -270,5 +273,10 @@ func (c *Config) Validate() error {
default: default:
errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant", "quadrant" or "cells"`, c.UI.CoverStyle)) errs = append(errs, fmt.Errorf(`ui.cover_style: %q must be "auto", "kitty", "sextant", "quadrant" or "cells"`, c.UI.CoverStyle))
} }
switch c.UI.Theme {
case "", "auto", "dark", "light":
default:
errs = append(errs, fmt.Errorf(`ui.theme: %q must be "auto", "dark" or "light"`, c.UI.Theme))
}
return errors.Join(errs...) return errors.Join(errs...)
} }
+12
View File
@@ -110,6 +110,18 @@ func TestCoverStyleValidation(t *testing.T) {
} }
} }
func TestThemeValidation(t *testing.T) {
base := `{"radarr":{"url":"http://r","apikey":"k"},"sonarr":{"url":"http://s","apikey":"k"},"sabnzbd":{"url":"http://a","apikey":"k"},"ui":{"theme":`
for _, ok := range []string{`"auto"`, `"dark"`, `"light"`, `""`} {
if _, err := Parse([]byte(base + ok + `}}`)); err != nil {
t.Errorf("%s rejected: %v", ok, err)
}
}
if _, err := Parse([]byte(base + `"solarized"}}`)); err == nil {
t.Error("unknown theme accepted")
}
}
func TestSaveRoundTripAndPermissions(t *testing.T) { func TestSaveRoundTripAndPermissions(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.json") path := filepath.Join(dir, "config.json")
+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. // New builds the application shell: a tab bar, one page per view and a status bar.
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 set the colors
// copies the default background. // before any widget copies them.
backgroundNote := useTerminalBackground(terminalAnswers(), os.Getenv) th, themeNote := chooseTheme(cfg.UI.Theme, terminalAnswers())
th.apply()
a := &App{ a := &App{
app: tview.NewApplication(), app: tview.NewApplication(),
tabs: tview.NewTextView().SetDynamicColors(true), tabs: tview.NewTextView().SetDynamicColors(true),
@@ -139,7 +140,7 @@ func New(cfg *config.Config) *App {
if a.covers { if a.covers {
a.logs.add(levelInfo, coverNote) a.logs.add(levelInfo, coverNote)
} }
a.logs.add(levelInfo, backgroundNote) a.logs.add(levelInfo, themeNote)
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{
@@ -588,11 +589,11 @@ func (a *App) renderTabs() {
for i, v := range a.views { for i, v := range a.views {
switch { switch {
case a.narrow && i == a.active: 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: case a.narrow:
s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name) s += fmt.Sprintf("[gray]%c %s[-] ", v.key, v.name)
case i == a.active: 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: default:
s += fmt.Sprintf("[gray] %c %s [-]", v.key, v.name) 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.img == nil {
if c.note != "" { 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 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. // header writes the header row; column expand grows to fill the width.
func header(t *tview.Table, expand int, cols ...string) { func header(t *tview.Table, expand int, cols ...string) {
for i, c := range cols { 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 { if i == expand {
cell.SetExpansion(1) cell.SetExpansion(1)
} }
+2 -2
View File
@@ -127,7 +127,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
box.SetChecked(want) box.SetChecked(want)
return return
} }
box.SetLabelColor(tcell.ColorGray) box.SetLabelColor(named("gray"))
box.SetChecked(false) box.SetChecked(false)
} }
} }
@@ -260,7 +260,7 @@ func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
func (h *formHeading) Draw(screen tcell.Screen) { func (h *formHeading) Draw(screen tcell.Screen) {
h.Box.DrawForSubclass(screen, h) h.Box.DrawForSubclass(screen, h)
x, y, w, _ := h.GetInnerRect() 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 // 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)"}, {"t", "retry a failed download (history)"},
{"r", "refresh now"}, {"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"}, {"↑↓", "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"}, {"Tab", "jump between the fields and Save / Revert"},
{"Space", "toggle a checkbox; press a button"}, {"Space", "toggle a checkbox; press a button"},
{"Test", "(button below the SABnzbd settings) checks the three services with the values typed in"}, {"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{ {title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{
{"↑↓, PgUp, PgDn", "scroll"}, {"↑↓, 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.cur = kind
v.input.SetLabel("Filter " + kind.String() + ": ") v.input.SetLabel("Filter " + kind.String() + ": ")
v.input.SetText(p.text) v.input.SetText(p.text)
p.table.SetBorderColor(tcell.ColorYellow) p.table.SetBorderColor(named("yellow"))
v.renderDetails() v.renderDetails()
}) })
p.table.SetBlurFunc(func() { p.table.SetBorderColor(tview.Styles.BorderColor) }) 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 { 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. // 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 x := w - width - 1
fg := t.level.color() fg := t.level.color()
if t.expires.Sub(now) < toastFade { 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++ { for cx := x; cx < x+width; cx++ {
screen.SetContent(cx, y, ' ', nil, style) 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 // probeTerminal asks the terminal whether it understands the Kitty graphics
// protocol (a tiny query image, which a capable terminal answers with "OK" and // 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 // 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 // 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. // 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) 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 { if _, err := tty.WriteString(query); err != nil {
return info return info
} }
+1 -1
View File
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
v.table.Clear() v.table.Clear()
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} { 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" { if c == "Title" {
cell.SetExpansion(1) cell.SetExpansion(1)
} }
+33 -4
View File
@@ -24,16 +24,23 @@ type settingsValues struct {
covers bool covers bool
coverStyle string coverStyle string
notify, bell bool 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. // settingsFrom reads the settings in use.
func settingsFrom(c *config.Config) settingsValues { func settingsFrom(c *config.Config) settingsValues {
style := c.UI.CoverStyle style, th := c.UI.CoverStyle, c.UI.Theme
if style == "" { if style == "" {
style = "auto" style = "auto"
} }
if th == "" {
th = "auto"
}
return settingsValues{ return settingsValues{
radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey, radarrURL: c.Radarr.URL, radarrKey: c.Radarr.APIKey,
radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder, radarrProfile: c.Radarr.DefaultQualityProfile, radarrFolder: c.Radarr.DefaultRootFolder,
@@ -43,6 +50,7 @@ func settingsFrom(c *config.Config) settingsValues {
refresh: strconv.Itoa(c.UI.RefreshSeconds), refresh: strconv.Itoa(c.UI.RefreshSeconds),
covers: c.UI.ShowCovers(), coverStyle: style, covers: c.UI.ShowCovers(), coverStyle: style,
notify: c.UI.ShouldNotifyDownloads(), bell: c.UI.Bell, 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" { if v.coverStyle == "auto" {
raw.UI.CoverStyle = "" raw.UI.CoverStyle = ""
} }
raw.UI.Theme = v.theme
if v.theme == "auto" {
raw.UI.Theme = ""
}
if _, err := raw.Effective(os.Getenv); err != nil { if _, err := raw.Effective(os.Getenv); err != nil {
return raw, err return raw, err
@@ -97,7 +109,7 @@ func boolSetting(old *bool, value, def bool) *bool {
} }
// settingsView edits the configuration file's settings. Everything except // 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 { type settingsView struct {
app *App app *App
root *tview.Flex root *tview.Flex
@@ -112,6 +124,7 @@ type settingsView struct {
refresh *tview.InputField refresh *tview.InputField
covers *tview.Checkbox covers *tview.Checkbox
style *tview.DropDown style *tview.DropDown
theme *tview.DropDown
notify *tview.Checkbox notify *tview.Checkbox
bell *tview.Checkbox bell *tview.Checkbox
showKeys *tview.Checkbox showKeys *tview.Checkbox
@@ -171,6 +184,8 @@ func newSettingsView(app *App) *settingsView {
f.AddFormItem(v.covers) f.AddFormItem(v.covers)
v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() }) v.style = tview.NewDropDown().SetLabel("Poster style").SetOptions(coverStyles, func(string, int) { v.edited() })
f.AddFormItem(v.style) 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() }) v.notify = tview.NewCheckbox().SetLabel("Notify about finished downloads").SetChangedFunc(func(bool) { v.edited() })
f.AddFormItem(v.notify) f.AddFormItem(v.notify)
v.bell = tview.NewCheckbox().SetLabel("Ring the terminal bell too").SetChangedFunc(func(bool) { v.edited() }) 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) v.style.SetCurrentOption(i)
} }
} }
for i, name := range themes {
if name == s.theme {
v.theme.SetCurrentOption(i)
}
}
v.notify.SetChecked(s.notify) v.notify.SetChecked(s.notify)
v.bell.SetChecked(s.bell) v.bell.SetChecked(s.bell)
v.dirty = false v.dirty = false
@@ -302,6 +322,10 @@ func (v *settingsView) read() settingsValues {
if i >= 0 && i < len(coverStyles) { if i >= 0 && i < len(coverStyles) {
style = coverStyles[i] style = coverStyles[i]
} }
th := "auto"
if i, _ := v.theme.GetCurrentOption(); i >= 0 && i < len(themes) {
th = themes[i]
}
return settingsValues{ return settingsValues{
radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"), radarrURL: text("radarr.url"), radarrKey: text("radarr.apikey"),
radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"), radarrProfile: text("radarr.profile"), radarrFolder: text("radarr.folder"),
@@ -311,6 +335,7 @@ func (v *settingsView) read() settingsValues {
refresh: v.refresh.GetText(), refresh: v.refresh.GetText(),
covers: v.covers.IsChecked(), coverStyle: style, covers: v.covers.IsChecked(), coverStyle: style,
notify: v.notify.IsChecked(), bell: v.bell.IsChecked(), notify: v.notify.IsChecked(), bell: v.bell.IsChecked(),
theme: th,
} }
} }
@@ -322,7 +347,7 @@ func (v *settingsView) describe() {
} else { } else {
b.WriteString("Config file: " + tview.Escape(v.app.cfgPath) + "\n") 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 { if len(v.overridden) > 0 {
var names []string var names []string
for _, env := range v.overridden { for _, env := range v.overridden {
@@ -412,9 +437,13 @@ func (v *settingsView) save() {
v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]") v.report("[red]Saved, but not applied: " + tview.Escape(err.Error()) + "[-]")
return return
} }
themeChanged := effective.UI.Theme != v.app.cfg.UI.Theme
v.app.cfgFile = &raw v.app.cfgFile = &raw
v.app.applyConfig(effective) v.app.applyConfig(effective)
v.dirty = false v.dirty = false
v.report("[green]Saved to " + tview.Escape(v.app.cfgPath) + " and applied.[-]") 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") v.app.Notify(levelSuccess, "Settings saved")
} }
+2 -1
View File
@@ -47,12 +47,13 @@ func TestApplySettings(t *testing.T) {
v.notify = false v.notify = false
v.bell = true v.bell = true
v.coverStyle = "quadrant" v.coverStyle = "quadrant"
v.theme = "light"
got, err = applySettings(raw, v, nil) got, err = applySettings(raw, v, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" || 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) t.Errorf("edits lost: %+v", got)
} }
+2 -84
View File
@@ -2,16 +2,11 @@ package ui
import ( import (
"bytes" "bytes"
"fmt"
"image/color" "image/color"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "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. // 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 graphics bool // the Kitty graphics protocol is supported
bg color.RGBA // its background color, if hasBG bg color.RGBA // its background color, if hasBG
hasBG bool hasBG bool
fg color.RGBA // its text color, if hasFG
hasFG bool
} }
var ( var (
@@ -39,13 +32,12 @@ var terminalAnswers = func() terminalInfo {
// parseTerminalAnswers reads the answers to probeTerminal's questions. // parseTerminalAnswers reads the answers to probeTerminal's questions.
func parseTerminalAnswers(got []byte) (info terminalInfo) { func parseTerminalAnswers(got []byte) (info terminalInfo) {
info.graphics = bytes.Contains(got, []byte("_Gi=31;OK")) info.graphics = bytes.Contains(got, []byte("_Gi=31;OK"))
info.fg, info.hasFG = parseColorAnswer(got, "10")
info.bg, info.hasBG = parseColorAnswer(got, "11") info.bg, info.hasBG = parseColorAnswer(got, "11")
return info return info
} }
// parseColorAnswer finds the answer to an OSC 10 (text) or 11 (background) // parseColorAnswer finds the answer to an OSC color query such as 11
// color query: ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each // (background): ESC ] 11 ; rgb:RRRR/GGGG/BBBB, ended by ST or BEL. Each
// component has 1 to 4 hex digits. // component has 1 to 4 hex digits.
func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) { func parseColorAnswer(got []byte, osc string) (color.RGBA, bool) {
prefix := []byte("\x1b]" + osc + ";rgb:") 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 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). // luminance is the relative brightness of c, from 0 (black) to 1 (white).
func luminance(c color.RGBA) float64 { func luminance(c color.RGBA) float64 {
return (0.2126*float64(c.R) + 0.7152*float64(c.G) + 0.0722*float64(c.B)) / 255 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 ( import (
"image/color" "image/color"
"strings"
"testing" "testing"
"github.com/gdamore/tcell/v2" "github.com/gdamore/tcell/v2"
@@ -32,64 +31,66 @@ func TestParseTerminalAnswers(t *testing.T) {
} }
} }
func TestUseTerminalBackground(t *testing.T) { func TestChooseTheme(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] } }
dark := terminalInfo{bg: color.RGBA{30, 30, 46, 255}, hasBG: true} 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} light := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true}
truecolor := env(map[string]string{"COLORTERM": "truecolor"})
for _, tc := range []struct { for _, tc := range []struct {
name string setting string
info terminalInfo info terminalInfo
env func(string) string want string
want bool
}{ }{
{"dark background, true color", dark, truecolor, true}, {"", light, "light"},
{"no true color", dark, env(map[string]string{"TERM": "vt100"}), false}, {"auto", dark, "dark"},
{"ghostty without COLORTERM", dark, env(map[string]string{"TERM": "xterm-ghostty"}), true}, {"auto", terminalInfo{}, "dark"},
{"true color disabled for tcell", dark, env(map[string]string{"TERM": "xterm-ghostty", "TCELL_TRUECOLOR": "disable"}), false}, {"dark", light, "dark"},
{"light background", light, truecolor, true}, {"light", dark, "light"},
{"no answer", terminalInfo{}, truecolor, false},
} { } {
tview.Styles.PrimitiveBackgroundColor = tcell.ColorBlack if th, note := chooseTheme(tc.setting, tc.info); th.name != tc.want {
note := useTerminalBackground(tc.info, tc.env) t.Errorf("%q with %+v: %s (%s)", tc.setting, tc.info, th.name, note)
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)
} }
} }
} }
func TestLightBackgroundUsesTheTextColor(t *testing.T) { func TestThemeApply(t *testing.T) {
defer func(old tview.Theme, tab string) { tview.Styles, activeTabTag = old, tab }(tview.Styles, activeTabTag) oldStyles, oldCurrent := tview.Styles, current
latte := terminalInfo{bg: color.RGBA{239, 241, 245, 255}, hasBG: true, fg: color.RGBA{76, 79, 105, 255}, hasFG: true} oldNames := map[string]tcell.Color{}
useTerminalBackground(latte, func(k string) string { return map[string]string{"COLORTERM": "truecolor"}[k] }) for k, v := range tcell.ColorNames {
st := tview.Styles oldNames[k] = v
if st.PrimaryTextColor != tcell.NewRGBColor(76, 79, 105) || st.BorderColor != st.PrimaryTextColor {
t.Errorf("text %v, borders %v", st.PrimaryTextColor, st.BorderColor)
} }
if st.ContrastBackgroundColor == tcell.ColorBlue { defer func() {
t.Error("input fields should not stay palette blue on a light background") 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]" { // Color names in texts mean the theme's colors.
t.Errorf("active tab %s", activeTabTag) 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. if current.name != "light" {
tview.Styles = st t.Error("current theme not set")
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)
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)
}