package tui
import (
"github.com/charmbracelet/lipgloss"
"codey/internal/config"
)
// A theme is a palette, not a set of styles: the styles in styles.go are
// derived from it, so a theme decides colour and nothing about layout. Adding
// one is adding an entry here and a name to config.Themes.
//
// Every theme names all eleven roles rather than inheriting the missing ones
// from a base. Inheritance would make a half-specified theme render as a mix of
// two palettes, which is the one outcome worse than an ugly theme.
type theme struct {
name string
// blurb is what the wizard says about it, in the wizard's own voice.
blurb string
accent lipgloss.AdaptiveColor // the identity colour: one per theme
text lipgloss.AdaptiveColor
user lipgloss.AdaptiveColor
dim lipgloss.AdaptiveColor
faint lipgloss.AdaptiveColor
ok lipgloss.AdaptiveColor
err lipgloss.AdaptiveColor
warn lipgloss.AdaptiveColor
info lipgloss.AdaptiveColor
pillBg lipgloss.AdaptiveColor
// selBg is what a mouse selection is painted on. It is a tint of the
// theme's own accent rather than a neutral grey, so a selection cannot be
// mistaken for the pill background the status bar already uses.
selBg lipgloss.AdaptiveColor
}
// themes are in the order the wizard offers them; ember is first because it
// is the default.
//
// The greys differ per theme rather than being shared: a neutral grey beside a
// warm accent reads as slightly blue, and beside a cool one as slightly
// brown. Tinting each theme's greys toward its own accent is what keeps the
// palette reading as one decision instead of two.
var themes = []theme{{
name: "ember",
blurb: "Warm coral on a neutral ground. The original.",
accent: lipgloss.AdaptiveColor{Light: "#b4532f", Dark: "#d97757"},
text: lipgloss.AdaptiveColor{Light: "#1c1b1a", Dark: "#e8e6e3"},
user: lipgloss.AdaptiveColor{Light: "#4b4945", Dark: "#b3ada4"},
dim: lipgloss.AdaptiveColor{Light: "#6f6a66", Dark: "#8a847d"},
faint: lipgloss.AdaptiveColor{Light: "#c9c4be", Dark: "#3f3b38"},
ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#5dbb7a"},
err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#eb6f6f"},
warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e0a458"},
info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#7aa2e3"},
pillBg: lipgloss.AdaptiveColor{Light: "#ebe7e2", Dark: "#2b2825"},
selBg: lipgloss.AdaptiveColor{Light: "#f6d8cb", Dark: "#5c3527"},
}, {
name: "mint",
blurb: "Cool green. Easiest on a bright screen.",
accent: lipgloss.AdaptiveColor{Light: "#12796a", Dark: "#4ec9b0"},
text: lipgloss.AdaptiveColor{Light: "#16201e", Dark: "#e3ebe8"},
user: lipgloss.AdaptiveColor{Light: "#42504d", Dark: "#a8b8b4"},
dim: lipgloss.AdaptiveColor{Light: "#65736f", Dark: "#7d8d89"},
faint: lipgloss.AdaptiveColor{Light: "#c0cbc8", Dark: "#36403e"},
ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#7fd48f"},
err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#f07a7a"},
warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e8bc6a"},
info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#79a8d8"},
pillBg: lipgloss.AdaptiveColor{Light: "#e4ecea", Dark: "#232b29"},
selBg: lipgloss.AdaptiveColor{Light: "#cbe8e1", Dark: "#1f4c44"},
}, {
name: "iris",
blurb: "Violet and slate. Quiet under low light.",
accent: lipgloss.AdaptiveColor{Light: "#6a4ac2", Dark: "#b18cf0"},
text: lipgloss.AdaptiveColor{Light: "#1b1a22", Dark: "#e7e4ee"},
user: lipgloss.AdaptiveColor{Light: "#4a4756", Dark: "#b0aac0"},
dim: lipgloss.AdaptiveColor{Light: "#6d6980", Dark: "#88829c"},
faint: lipgloss.AdaptiveColor{Light: "#c8c4d4", Dark: "#3c3848"},
ok: lipgloss.AdaptiveColor{Light: "#2f7d46", Dark: "#79c98f"},
err: lipgloss.AdaptiveColor{Light: "#b3261e", Dark: "#f07a90"},
warn: lipgloss.AdaptiveColor{Light: "#8a5a00", Dark: "#e2b062"},
info: lipgloss.AdaptiveColor{Light: "#2f5fa8", Dark: "#86a8ec"},
pillBg: lipgloss.AdaptiveColor{Light: "#eae7f2", Dark: "#282434"},
selBg: lipgloss.AdaptiveColor{Light: "#ded2f8", Dark: "#3d2f60"},
}, {
name: "mono",
blurb: "No accent hue. For screens and eyes that want none.",
// The accent is a contrast step rather than a hue, so the identity is
// carried by weight alone. The four status colours stay coloured even
// here: the context gauge and an error are the two things that must not
// need reading to be noticed.
accent: lipgloss.AdaptiveColor{Light: "#000000", Dark: "#ffffff"},
text: lipgloss.AdaptiveColor{Light: "#1a1a1a", Dark: "#e6e6e6"},
user: lipgloss.AdaptiveColor{Light: "#4a4a4a", Dark: "#b0b0b0"},
dim: lipgloss.AdaptiveColor{Light: "#6e6e6e", Dark: "#8a8a8a"},
faint: lipgloss.AdaptiveColor{Light: "#c6c6c6", Dark: "#3c3c3c"},
ok: lipgloss.AdaptiveColor{Light: "#3f6f4d", Dark: "#8fb89a"},
err: lipgloss.AdaptiveColor{Light: "#9c3b35", Dark: "#d99a9a"},
warn: lipgloss.AdaptiveColor{Light: "#7a6030", Dark: "#c8b083"},
info: lipgloss.AdaptiveColor{Light: "#4a5f7a", Dark: "#9aaec8"},
pillBg: lipgloss.AdaptiveColor{Light: "#e9e9e9", Dark: "#2a2a2a"},
selBg: lipgloss.AdaptiveColor{Light: "#d0d0d0", Dark: "#484848"},
}}
// defaultTheme is what an unconfigured Codey looks like, and the fallback
// for a theme name this build does not have.
const defaultTheme = "ember"
// lookupTheme finds a theme by name, falling back to the default. It never
// fails: a config naming a theme from a newer build should cost you the
// colours you picked, not the ability to start.
func lookupTheme(name string) theme {
for _, t := range themes {
if t.name == name {
return t
}
}
for _, t := range themes {
if t.name == defaultTheme {
return t
}
}
return themes[0]
}
// currentTheme is the palette in force. The wizard previews a theme by
// applying it for real, so this changes while the program runs.
var currentTheme = lookupTheme(defaultTheme)
// themeGen counts theme changes. Anything holding pre-rendered text — the
// transcript's scrollback, above all — compares it against the generation its
// cache was built under, because a palette swap has to repaint what is already
// on screen and not only what arrives next.
var themeGen int
// themeGeneration reports which palette the styles currently hold.
func themeGeneration() int { return themeGen }
func init() { applyTheme(defaultTheme) }
// applyTheme rebuilds every style from the named palette.
//
// The styles are package-level variables rather than fields on the model, so
// this is global mutable state — a deliberate trade. The alternative is
// threading a palette through every render function in the package, including
// the ones lipgloss calls for us, to serve a program that draws exactly one
// screen at a time. It is reassigned only from the Bubble Tea update loop,
// which is single-threaded, so the styles a frame reads are never mid-swap.
func applyTheme(name string) {
t := lookupTheme(name)
currentTheme = t
themeGen++
colCoral, colText, colUser = t.accent, t.text, t.user
colDim, colFaint = t.dim, t.faint
colOK, colErr, colWarn, colBlue, colPillBg = t.ok, t.err, t.warn, t.info, t.pillBg
colSelBg = t.selBg
styText = lipgloss.NewStyle().Foreground(colText)
styDim = lipgloss.NewStyle().Foreground(colDim)
styFaint = lipgloss.NewStyle().Foreground(colFaint)
styCoral = lipgloss.NewStyle().Foreground(colCoral)
styBold = lipgloss.NewStyle().Bold(true)
styErr = lipgloss.NewStyle().Foreground(colErr)
styWarn = lipgloss.NewStyle().Foreground(colWarn)
styOK = lipgloss.NewStyle().Foreground(colOK)
styUserMark = lipgloss.NewStyle().Foreground(colDim)
styUserText = lipgloss.NewStyle().Foreground(colUser)
styToolDot = lipgloss.NewStyle().Foreground(colCoral)
styTextDot = lipgloss.NewStyle().Foreground(colText)
styErrDot = lipgloss.NewStyle().Foreground(colErr)
styToolName = lipgloss.NewStyle().Foreground(colText).Bold(true)
styToolArgs = lipgloss.NewStyle().Foreground(colDim)
styResult = lipgloss.NewStyle().Foreground(colDim)
styLocalShell = lipgloss.NewStyle().Foreground(colOK)
styLocalPython = lipgloss.NewStyle().Foreground(colBlue)
styDiffAdd = lipgloss.NewStyle().Foreground(colOK)
styDiffDel = lipgloss.NewStyle().Foreground(colErr)
styDiffHunk = lipgloss.NewStyle().Foreground(colCoral)
styDiffHead = lipgloss.NewStyle().Foreground(colDim).Bold(true)
styName = lipgloss.NewStyle().Foreground(colBlue).Background(colPillBg).Padding(0, 1)
styPill = lipgloss.NewStyle().Foreground(colDim).Background(colPillBg).Padding(0, 1)
styKey = lipgloss.NewStyle().Foreground(colCoral).Bold(true)
styHeading = lipgloss.NewStyle().Foreground(colCoral).Bold(true)
styBoxFocus = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colCoral).
Padding(0, 1)
styBoxBlur = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colFaint).
Padding(0, 1)
styModal = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colCoral).
Padding(0, 1)
// The foreground is named rather than inherited: the marked cells are
// stripped of their own colour before they are painted, so whatever they
// were — dim grey, a diff's red, a comment in a code fence — has to come
// back as something that reads on this background.
stySelect = lipgloss.NewStyle().Foreground(colText).Background(colSelBg)
// Markdown is styled from the same palette, and its stylesheet is baked
// rather than read per draw, so it has to be rebuilt here too.
resetMarkdown()
}
// themeNames reports the themes in offer order. It is derived from themes so
// the wizard cannot drift out of step with what exists, and checked against
// config.Themes by a test so the two lists cannot disagree either.
func themeNames() []string {
out := make([]string, 0, len(themes))
for _, t := range themes {
out = append(out, t.name)
}
return out
}
// resolveTheme picks the theme to start in: what was configured, else the
// default. It is here rather than in config because config deliberately knows
// only the names.
func resolveTheme(cfg config.Config) string {
if config.ValidTheme(cfg.Theme) {
return cfg.Theme
}
return defaultTheme
}