Vectrum
Sign In
247 lines · 10.5 KB · Go
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)

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