diff --git a/CHANGELOG.md b/CHANGELOG.md index 52091fd..3d1a81e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,12 @@ ## Unreleased -- **The terminal's own colors, light themes too:** the panes used color 0 of the terminal's palette - ("black"), which color themes often set to a grey that differs from their background (e.g. - Catppuccin), so the whole program looked washed out, and on light themes it was a dark grey slab - with white text. The terminal is now asked for its background and text colors at startup. Dark - backgrounds are used as they are; on light ones the text, borders, input fields and the active - tab follow the terminal's text color as well. Needs 24-bit color; the Log says which colors are - used and why. +- **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 + 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. - **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 0ec1f4b..2931fd5 100644 --- a/README.md +++ b/README.md @@ -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, 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 - 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 - 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. - **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, 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, `"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. 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" }, "sonarr": { "url": "http://localhost:8989", "apikey": "...", "default_quality_profile": "HD-1080p", "default_root_folder": "/tv" }, "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 } } ``` diff --git a/SPECS.md b/SPECS.md index aa3bfa2..6dad029 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: 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. diff --git a/config.example.json b/config.example.json index bcab08a..5e0c7f2 100644 --- a/config.example.json +++ b/config.example.json @@ -20,6 +20,7 @@ "covers": true, "notify_downloads": true, "bell": false, - "cover_style": "auto" + "cover_style": "auto", + "theme": "auto" } } diff --git a/internal/config/config.go b/internal/config/config.go index 201d3af..2092020 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 // fontconfig finds a font with the glyphs, else quadrant). The block styles are also the fallback for "kitty". 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. @@ -270,5 +273,10 @@ func (c *Config) Validate() error { default: 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...) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 81e727c..eae23f0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { dir := t.TempDir() path := filepath.Join(dir, "config.json") diff --git a/internal/ui/app.go b/internal/ui/app.go index 8c405bd..d8fdbb0 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -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) } diff --git a/internal/ui/cover.go b/internal/ui/cover.go index a25ef3c..1eef7c2 100644 --- a/internal/ui/cover.go +++ b/internal/ui/cover.go @@ -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 } diff --git a/internal/ui/downloads.go b/internal/ui/downloads.go index 2fead73..a63286e 100644 --- a/internal/ui/downloads.go +++ b/internal/ui/downloads.go @@ -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) } diff --git a/internal/ui/form.go b/internal/ui/form.go index fab928e..ae7341a 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -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 diff --git a/internal/ui/help.go b/internal/ui/help.go index 2cd7a45..e6d910d 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -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"}, diff --git a/internal/ui/library.go b/internal/ui/library.go index 4c4b0f5..a601ce0 100644 --- a/internal/ui/library.go +++ b/internal/ui/library.go @@ -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) }) diff --git a/internal/ui/notify.go b/internal/ui/notify.go index e2c4eee..656c22a 100644 --- a/internal/ui/notify.go +++ b/internal/ui/notify.go @@ -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) } diff --git a/internal/ui/probe_unix.go b/internal/ui/probe_unix.go index 4112a58..ca2f4c7 100644 --- a/internal/ui/probe_unix.go +++ b/internal/ui/probe_unix.go @@ -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 } diff --git a/internal/ui/releases.go b/internal/ui/releases.go index 2060638..faec334 100644 --- a/internal/ui/releases.go +++ b/internal/ui/releases.go @@ -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) } diff --git a/internal/ui/settings.go b/internal/ui/settings.go index 310a9e0..e42460a 100644 --- a/internal/ui/settings.go +++ b/internal/ui/settings.go @@ -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") } diff --git a/internal/ui/settings_test.go b/internal/ui/settings_test.go index 505bb09..923ff73 100644 --- a/internal/ui/settings_test.go +++ b/internal/ui/settings_test.go @@ -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) } diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go index e117a36..7412936 100644 --- a/internal/ui/terminal.go +++ b/internal/ui/terminal.go @@ -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 diff --git a/internal/ui/terminal_test.go b/internal/ui/terminal_test.go index 53d1e73..4e99011 100644 --- a/internal/ui/terminal_test.go +++ b/internal/ui/terminal_test.go @@ -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) + } + } } } diff --git a/internal/ui/theme.go b/internal/ui/theme.go new file mode 100644 index 0000000..b69dda7 --- /dev/null +++ b/internal/ui/theme.go @@ -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) +}