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
This commit is contained in:
2026-10-21 12:11:19 +02:00
co-authored by Claude Opus 5.5
parent 24f7c38a5b
commit 67f5cf6ce7
11 changed files with 171 additions and 38 deletions
+82 -11
View File
@@ -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 {