Files
arr-tui/internal/ui/theme.go
T
nilsandClaude Opus 5.5 67f5cf6ce7 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019reK4DUGYqDUjniWYJvwUW
2026-10-21 12:11:19 +02:00

182 lines
6.2 KiB
Go

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),
}
)
// 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)"
}
// 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
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
}
}
// 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 {
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)
}