From 67f5cf6ce701e4fc7f6637eba1defe43ea9dc408 Mon Sep 17 00:00:00 2001 From: Nils Stinnesbeck Date: Wed, 21 Oct 2026 12:11:19 +0200 Subject: [PATCH] Switch the theme live Widgets copy their colors when they are created, so they now get role tokens (one unique RGB value per role) instead of colors, and a wrapper around the tcell screen replaces the tokens with the current theme's colors in SetContent. Changing the theme in the Config view swaps that table; the next draw recolors everything. Poster colors that equal a token are nudged off it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW --- CHANGELOG.md | 4 +- README.md | 6 +-- SPECS.md | 2 +- internal/ui/app.go | 27 ++++++++++- internal/ui/configapply.go | 3 ++ internal/ui/cover.go | 4 +- internal/ui/help.go | 2 +- internal/ui/notify.go | 2 +- internal/ui/settings.go | 12 ++--- internal/ui/terminal_test.go | 54 +++++++++++++++++---- internal/ui/theme.go | 93 +++++++++++++++++++++++++++++++----- 11 files changed, 171 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d1a81e..e0f325f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,13 @@ ## Unreleased - **Dark and light themes:** fixed colors that look the same in every terminal (`ui.theme`: `auto`, - `dark` or `light`, also in the Config view; applies at the next start). Before, the panes used + `dark` or `light`, also in the Config view, where a change recolors everything at once). Before, the panes used color 0 of the terminal's palette, which color themes often set to a grey that is not their background (e.g. Catppuccin), so the program looked washed out, and on light themes it was a dark grey slab with white text. `auto` asks the terminal for its background color and picks light or dark to match; the Log says which and why. +- **Space presses buttons** (Save, Revert, the buttons of dialogs), as the help says; before only + Enter did, except for Test. - **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 out was more output per step than the terminal could draw, so moving through a list lagged. diff --git a/README.md b/README.md index 2931fd5..445be68 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video - **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, theme, notifications. *Test* checks the values you typed before saving; *Save* writes the file (permissions kept, `0600` for a - new file) and applies the changes at once (the theme at the next start). Values that come from `ARR_TUI_*` + new file) and applies the changes at once, no restart. Values that come from `ARR_TUI_*` 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. - **Download notifications**: a popup (and a log entry) when a SABnzbd download finishes or fails, @@ -39,8 +39,8 @@ A terminal UI to search for movies and series with [Radarr](https://radarr.video `"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. + background, else dark), `dark` or `light`; it can also be switched in the Config view, which + recolors everything at once. - 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 diff --git a/SPECS.md b/SPECS.md index 6dad029..d097576 100644 --- a/SPECS.md +++ b/SPECS.md @@ -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. -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. +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. Themes switch while the program runs: widgets copy tview's default styles when they are created, so instead of colors they get role tokens, one unique RGB value per role (background, text, border, field, yellow, ...; `0xa517nn`), set as tview's defaults before any widget exists and as the color names used in texts in tcell's table (`[yellow]` is the yellow role, `white` text and `black` background, so the active tab `[black:white]` follows too). The tcell screen is wrapped (`themedScreen`), and every `SetContent` replaces role tokens with the current theme's colors; tview clears and redraws the whole screen each frame, so switching the theme is a change of that table and one draw. Other colors pass unchanged: poster cells (a poster color that equals a token is nudged by one step) and the image-id colors of real-image placeholders. 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. diff --git a/internal/ui/app.go b/internal/ui/app.go index d8fdbb0..a0de7da 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -30,6 +30,8 @@ type view struct { // App is the top-level arr-tui application. type App struct { app *tview.Application + theme theme // the colors in use + themed *themedScreen // draws with them; nil until Run tabs *tview.TextView pages *tview.Pages status *tview.TextView @@ -103,7 +105,7 @@ func New(cfg *config.Config) *App { // 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() + useTokens() a := &App{ app: tview.NewApplication(), tabs: tview.NewTextView().SetDynamicColors(true), @@ -140,6 +142,7 @@ func New(cfg *config.Config) *App { if a.covers { a.logs.add(levelInfo, coverNote) } + a.theme = th a.logs.add(levelInfo, themeNote) sabClient := sabnzbd.NewClient(cfg.SABnzbd.URL, cfg.SABnzbd.APIKey) a.sab = sabClient @@ -394,12 +397,34 @@ func (a *App) Run() error { a.gfx.deleteAll() } }() + // The screen draws through the theme (see themedScreen). + s, err := tcell.NewScreen() + if err != nil { + return err + } + a.themed = newThemedScreen(s, a.theme) + a.app.SetScreen(a.themed) // initializes it + if a.themed.initErr != nil { + return a.themed.initErr + } + a.themed.DisableMouse() + a.themed.DisablePaste() + go a.watchServices(stop) a.setNotifyDownloads(a.notifyDone) defer a.setNotifyDownloads(false) return a.app.Run() } +// setTheme switches the colors while the program runs. +func (a *App) setTheme(t theme, note string) { + a.theme = t + if a.themed != nil { + a.themed.setTheme(t) + } + a.logs.add(levelInfo, note) +} + // setNotifyDownloads starts or stops the background watch for finished downloads. func (a *App) setNotifyDownloads(on bool) { a.notifyDone = on diff --git a/internal/ui/configapply.go b/internal/ui/configapply.go index 41d92f4..e99ebf3 100644 --- a/internal/ui/configapply.go +++ b/internal/ui/configapply.go @@ -35,6 +35,9 @@ func (a *App) applyConfig(c *config.Config) { a.downloads.setInterval(time.Duration(c.UI.RefreshSeconds) * time.Second) } a.bell = c.UI.Bell + if old.UI.Theme != c.UI.Theme { + a.setTheme(chooseTheme(c.UI.Theme, terminalAnswers())) + } if c.UI.ShouldNotifyDownloads() != a.notifyDone { a.setNotifyDownloads(c.UI.ShouldNotifyDownloads()) } diff --git a/internal/ui/cover.go b/internal/ui/cover.go index 1eef7c2..19d7922 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -458,8 +458,8 @@ func (c *coverView) build(cols, rows int) []styledCell { } ch, fg, bg := st.bestCell(px) cells = append(cells, styledCell{ch, - tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B)), - tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B))}) + notToken(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))), + notToken(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))}) } } return cells diff --git a/internal/ui/help.go b/internal/ui/help.go index e6d910d..9c5f890 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -71,7 +71,7 @@ var helpSections = []helpSection{ {"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 (the theme at the next start); Revert discards edits"}, + {"Save, Revert", "(at the bottom) Save writes the file and applies everything at once; Revert discards edits"}, }}, {title: "Log (F7)", note: "Popups fade away by themselves; everything they said is kept here.", items: []helpItem{ {"↑↓, PgUp, PgDn", "scroll"}, diff --git a/internal/ui/notify.go b/internal/ui/notify.go index 656c22a..ea703fb 100644 --- a/internal/ui/notify.go +++ b/internal/ui/notify.go @@ -144,7 +144,7 @@ func (r *toastRoot) Draw(screen tcell.Screen) { if t.expires.Sub(now) < toastFade { fg = named("gray") // fade out } - style := tcell.StyleDefault.Background(current.toast).Foreground(fg) + style := tcell.StyleDefault.Background(tokens.toast).Foreground(fg) for cx := x; cx < x+width; cx++ { screen.SetContent(cx, y, ' ', nil, style) } diff --git a/internal/ui/settings.go b/internal/ui/settings.go index e42460a..1c7bf4e 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -108,8 +108,8 @@ func boolSetting(old *bool, value, def bool) *bool { return &value } -// settingsView edits the configuration file's settings. Everything except -// the theme applies right after saving; the theme at the next start. +// settingsView edits the configuration file's settings. Everything applies +// right after saving. type settingsView struct { app *App root *tview.Flex @@ -184,7 +184,7 @@ 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() }) + v.theme = tview.NewDropDown().SetLabel("Theme").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) @@ -347,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; only the theme waits for the next start.\n") + b.WriteString("Changes take effect when you save; nothing needs a restart.\n") if len(v.overridden) > 0 { var names []string for _, env := range v.overridden { @@ -437,13 +437,9 @@ 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") } diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go index 4e99011..b39e263 100644 --- a/internal/ui/terminal_test.go +++ b/internal/ui/terminal_test.go @@ -51,29 +51,65 @@ func TestChooseTheme(t *testing.T) { } } -func TestThemeApply(t *testing.T) { - oldStyles, oldCurrent := tview.Styles, current +func TestUseTokens(t *testing.T) { + oldStyles := tview.Styles oldNames := map[string]tcell.Color{} for k, v := range tcell.ColorNames { oldNames[k] = v } defer func() { - tview.Styles, current = oldStyles, oldCurrent + tview.Styles = oldStyles for k, v := range oldNames { tcell.ColorNames[k] = v } }() - lightTheme.apply() - if tview.Styles.PrimitiveBackgroundColor != lightTheme.bg || tview.Styles.PrimaryTextColor != lightTheme.text { + useTokens() + if tview.Styles.PrimitiveBackgroundColor != tokens.bg || tview.Styles.PrimaryTextColor != tokens.text { t.Error("tview styles not set") } - // Color names in texts mean the theme's colors. - if named("yellow") != lightTheme.yellow || tcell.GetColor("black") != lightTheme.bg || tcell.GetColor("white") != lightTheme.text { + // Color names in texts mean the roles. + if named("yellow") != tokens.yellow || tcell.GetColor("black") != tokens.bg || tcell.GetColor("white") != tokens.text { t.Error("color names not set") } - if current.name != "light" { - t.Error("current theme not set") +} + +func TestThemedScreenSwitchesLive(t *testing.T) { + sim := tcell.NewSimulationScreen("") + if err := sim.Init(); err != nil { + t.Fatal(err) + } + defer sim.Fini() + sim.SetSize(4, 1) + s := newThemedScreen(sim, darkTheme) + poster := tcell.NewRGBColor(10, 20, 30) + + draw := func() { + s.SetContent(0, 0, 'a', nil, tcell.StyleDefault.Foreground(tokens.yellow).Background(tokens.bg)) + s.SetContent(1, 0, 'b', nil, tcell.StyleDefault.Foreground(poster).Background(poster)) + } + check := func(th theme) { + _, _, st, _ := sim.GetContent(0, 0) + if fg, bg, _ := st.Decompose(); fg != th.yellow || bg != th.bg { + t.Errorf("%s: role colors drawn as %v on %v", th.name, fg, bg) + } + _, _, st, _ = sim.GetContent(1, 0) + if fg, bg, _ := st.Decompose(); fg != poster || bg != poster { + t.Errorf("%s: other colors changed to %v on %v", th.name, fg, bg) + } + } + draw() + check(darkTheme) + s.setTheme(lightTheme) + draw() + check(lightTheme) + + // A poster color that equals a role color is nudged off it. + if c := notToken(tokens.red); c == tokens.red { + t.Error("role color in a picture not nudged") + } + if notToken(poster) != poster { + t.Error("ordinary color changed") } } diff --git a/internal/ui/theme.go b/internal/ui/theme.go index b69dda7..2615e2c 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -46,9 +46,6 @@ var ( } ) -// 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. @@ -69,13 +66,34 @@ func chooseTheme(setting string, info terminalInfo) (theme, string) { 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 +// colors lists the theme's colors in a fixed order of roles. +func (t theme) colors() []tcell.Color { + return []tcell.Color{t.bg, t.text, t.border, t.field, t.fieldMuted, t.list, t.pressed, t.toast, + t.gray, t.yellow, t.green, t.red, t.blue, t.purple} +} + +// tokens is the theme the widgets are built with: every role has a color of +// its own that no theme uses, and themedScreen replaces it with the color of +// the current theme while drawing. That is how the theme changes while the +// program runs: widgets copy their colors when they are created, but what they +// copy is a role, not a color. +var tokens = func() theme { + var c [14]tcell.Color + for i := range c { + c[i] = tcell.NewRGBColor(0xa5, 0x17, int32(i)) + } + return theme{name: "tokens", bg: c[0], text: c[1], border: c[2], field: c[3], fieldMuted: c[4], list: c[5], pressed: c[6], toast: c[7], + gray: c[8], yellow: c[9], green: c[10], red: c[11], blue: c[12], purple: c[13]} +}() + +// useTokens makes the role colors the defaults 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]" is the theme's yellow, +// "white" its text color and "black" its background (the active tab is +// "[black:white]"). +func useTokens() { + t := tokens st := &tview.Styles st.PrimitiveBackgroundColor = t.bg st.ContrastBackgroundColor = t.field @@ -97,7 +115,60 @@ func (t theme) apply() { } } -// named returns the theme's color for a name used in the texts. +// themedScreen draws with the current theme: it replaces the role colors of +// everything set on the screen by the theme's colors. Other colors (posters, +// the image ids of real-image posters) pass unchanged. +type themedScreen struct { + tcell.Screen + theme theme + colors map[tcell.Color]tcell.Color + initErr error +} + +func newThemedScreen(s tcell.Screen, t theme) *themedScreen { + ts := &themedScreen{Screen: s} + ts.setTheme(t) + return ts +} + +// setTheme switches the colors; the next draw shows them. +func (s *themedScreen) setTheme(t theme) { + s.theme = t + s.colors = map[tcell.Color]tcell.Color{} + actual := t.colors() + for i, tok := range tokens.colors() { + s.colors[tok] = actual[i] + } +} + +func (s *themedScreen) SetContent(x, y int, primary rune, combining []rune, style tcell.Style) { + fg, bg, _ := style.Decompose() + if c, ok := s.colors[fg]; ok { + style = style.Foreground(c) + } + if c, ok := s.colors[bg]; ok { + style = style.Background(c) + } + s.Screen.SetContent(x, y, primary, combining, style) +} + +// Init remembers the error, which tview's SetScreen drops. +func (s *themedScreen) Init() error { + s.initErr = s.Screen.Init() + return s.initErr +} + +// notToken nudges a picture's color that happens to equal a role color, so +// themedScreen does not replace it. +func notToken(c tcell.Color) tcell.Color { + r, g, b := c.RGB() + if r == 0xa5 && g == 0x17 && b < int32(len(tokens.colors())) { + return tcell.NewRGBColor(r, g+1, b) + } + return c +} + +// named returns the role color for a name used in the texts. func named(name string) tcell.Color { return tcell.GetColor(name) } func hexColor(c color.RGBA) string {