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
182 lines
6.2 KiB
Go
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)
|
|
}
|