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) }