package tui import ( "github.com/charmbracelet/lipgloss" "github.com/johannSo/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) // The tab strip: the one on screen carries the accent on a pill, the rest // are grey names you can still read, and the mark is what a tab shouts // with when something happened in it while you were elsewhere. styTabOn = lipgloss.NewStyle().Foreground(colCoral).Background(colPillBg).Bold(true).Padding(0, 1) styTabOff = lipgloss.NewStyle().Foreground(colDim).Padding(0, 1) styTabMark = lipgloss.NewStyle().Foreground(colWarn).Padding(0, 1) 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) styBar = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). 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 }