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:
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+7
-6
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -276,7 +276,7 @@ func (v *downloadsView) renderSummary() {
|
||||
// header writes the header row; column expand grows to fill the width.
|
||||
func header(t *tview.Table, expand int, cols ...string) {
|
||||
for i, c := range cols {
|
||||
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
||||
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
|
||||
if i == expand {
|
||||
cell.SetExpansion(1)
|
||||
}
|
||||
|
||||
+2
-2
@@ -127,7 +127,7 @@ func bindSearchNow(box *tview.Checkbox, searchable func() bool, blocked func())
|
||||
box.SetChecked(want)
|
||||
return
|
||||
}
|
||||
box.SetLabelColor(tcell.ColorGray)
|
||||
box.SetLabelColor(named("gray"))
|
||||
box.SetChecked(false)
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func (h *formHeading) Focus(delegate func(p tview.Primitive)) {
|
||||
func (h *formHeading) Draw(screen tcell.Screen) {
|
||||
h.Box.DrawForSubclass(screen, h)
|
||||
x, y, w, _ := h.GetInnerRect()
|
||||
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, tcell.ColorYellow)
|
||||
tview.Print(screen, "[::b]"+tview.Escape(h.title)+"[::-]", x, y+1, w, tview.AlignLeft, named("yellow"))
|
||||
}
|
||||
|
||||
// formAction is a button inside a form's list of fields, so it can sit between
|
||||
|
||||
+2
-2
@@ -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"},
|
||||
|
||||
@@ -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) })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func (v *releasesView) render() {
|
||||
|
||||
v.table.Clear()
|
||||
for i, c := range []string{"", "Title", "Quality", "Size", "Age", "Indexer", "Score"} {
|
||||
cell := tview.NewTableCell(c).SetTextColor(tcell.ColorYellow).SetSelectable(false)
|
||||
cell := tview.NewTableCell(c).SetTextColor(named("yellow")).SetSelectable(false)
|
||||
if c == "Title" {
|
||||
cell.SetExpansion(1)
|
||||
}
|
||||
|
||||
+33
-4
@@ -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")
|
||||
}
|
||||
|
||||
@@ -47,12 +47,13 @@ func TestApplySettings(t *testing.T) {
|
||||
v.notify = false
|
||||
v.bell = true
|
||||
v.coverStyle = "quadrant"
|
||||
v.theme = "light"
|
||||
got, err = applySettings(raw, v, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Radarr.URL != "http://new:7878" || got.Radarr.DefaultQualityProfile != "HD-1080p" || got.SABnzbd.APIKey != "newkey" ||
|
||||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" {
|
||||
got.UI.RefreshSeconds != 10 || got.UI.Covers == nil || *got.UI.Covers || got.UI.NotifyDownloads == nil || *got.UI.NotifyDownloads || !got.UI.Bell || got.UI.CoverStyle != "quadrant" || got.UI.Theme != "light" {
|
||||
t.Errorf("edits lost: %+v", got)
|
||||
}
|
||||
|
||||
|
||||
+2
-84
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user