Files
terdut-tui/internal/theme/load.go
T
Niklas Ye 4a579bdbc6
CI / test (pull_request) Successful in 4s
Colour themes, defaulting to gruvbox dark
Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing
the palette meant editing the styles themselves. This puts a semantic token set
between the two: styles name roles, a theme supplies the colours.

internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the
new default, and gruvbox-light) and the loader for user themes in
~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and
override only what it cares about, and may shadow a built-in name to tweak it
in place. Unknown keys, malformed colours and incomplete themes are refused
with a message naming what went wrong.

Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour
terminals and honours NO_COLOR, so themes carry no fallbacks of their own.
An ANSI index is still accepted for anyone who would rather follow their
terminal's own palette.

The 21 package-level style vars become a Styles struct on the Model, which is
what rule 3 asked for all along; the four free functions in view.go take one as
their first argument. The embedded bubbles components are restyled from the
same tokens — otherwise a theme would leave a pink selected row and grey help
text behind. Note that the table's Cell style deliberately keeps no foreground:
bubbles renders cells before wrapping the row in Selected, so a colour there
cuts the selection highlight short.
2026-08-20 11:06:36 +02:00

197 lines
5.4 KiB
Go

package theme
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/charmbracelet/lipgloss"
"gopkg.in/yaml.v3"
)
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
// distinguishable from an empty one, which is what lets 'extends' overwrite
// only what the file actually mentions.
type rawTheme struct {
Extends *string `yaml:"extends"`
Primary *string `yaml:"primary"`
OnPrimary *string `yaml:"on_primary"`
Text *string `yaml:"text"`
Muted *string `yaml:"muted"`
Accent *string `yaml:"accent"`
Firing *string `yaml:"firing"`
Resolved *string `yaml:"resolved"`
Error *string `yaml:"error"`
SevCritical *string `yaml:"sev_critical"`
SevError *string `yaml:"sev_error"`
SevWarning *string `yaml:"sev_warning"`
SevInfo *string `yaml:"sev_info"`
}
// binding ties a YAML key to its raw value and the field it fills, so parsing,
// merging and the missing-token report all walk the same list.
type binding struct {
key string
src *string
dst *lipgloss.Color
}
func bindings(r *rawTheme, t *Theme) []binding {
return []binding{
{"primary", r.Primary, &t.Primary},
{"on_primary", r.OnPrimary, &t.OnPrimary},
{"text", r.Text, &t.Text},
{"muted", r.Muted, &t.Muted},
{"accent", r.Accent, &t.Accent},
{"firing", r.Firing, &t.Firing},
{"resolved", r.Resolved, &t.Resolved},
{"error", r.Error, &t.Error},
{"sev_critical", r.SevCritical, &t.SevCritical},
{"sev_error", r.SevError, &t.SevError},
{"sev_warning", r.SevWarning, &t.SevWarning},
{"sev_info", r.SevInfo, &t.SevInfo},
}
}
// tokenKeys lists the colour keys a theme file may set, in the order they are
// documented.
func tokenKeys() []string {
bs := bindings(&rawTheme{}, &Theme{})
keys := make([]string, len(bs))
for i, b := range bs {
keys[i] = b.key
}
return keys
}
func isTokenKey(k string) bool {
if k == "extends" {
return true
}
for _, want := range tokenKeys() {
if k == want {
return true
}
}
return false
}
// Load resolves a theme by name. An empty name is the default; otherwise a file
// in the user's themes directory wins over a built-in of the same name, so the
// documented way to tweak a built-in is to shadow it rather than rename it.
func Load(name string) (Theme, error) {
if name == "" {
return Default, nil
}
dir, err := os.UserConfigDir()
if err != nil {
// Built-ins do not need the disk, so a missing config directory only
// matters for user themes.
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
}
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
}
func loadFrom(dir, name string) (Theme, error) {
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
}
path := filepath.Join(dir, name+".yaml")
data, err := os.ReadFile(path)
switch {
case err == nil:
return parse(name, data)
case !os.IsNotExist(err):
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
}
if t, ok := builtins[name]; ok {
return t, nil
}
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
name, strings.Join(BuiltinNames(), ", "), path)
}
func parse(name string, data []byte) (Theme, error) {
// Check the keys before decoding, so a typo'd token reports itself by name
// alongside the ones that would have worked rather than surfacing yaml's
// message about an internal Go type.
var keys map[string]yaml.Node
if err := yaml.Unmarshal(data, &keys); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
for k := range keys {
if !isTokenKey(k) {
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
name, k, strings.Join(tokenKeys(), ", "))
}
}
var raw rawTheme
if err := yaml.Unmarshal(data, &raw); err != nil {
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
}
t := Theme{Name: name}
if raw.Extends != nil {
base, ok := builtins[*raw.Extends]
if !ok {
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
}
t = base
t.Name = name
}
var missing []string
for _, b := range bindings(&raw, &t) {
if b.src == nil {
if raw.Extends == nil {
missing = append(missing, b.key)
}
continue
}
c, err := parseColor(*b.src)
if err != nil {
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
}
*b.dst = c
}
if len(missing) > 0 {
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
name, strings.Join(missing, ", "), Default.Name)
}
return t, nil
}
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
// palette index for people who would rather follow their terminal's colours.
func parseColor(s string) (lipgloss.Color, error) {
if hexColor.MatchString(s) {
return lipgloss.Color(s), nil
}
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
// pass to the terminal verbatim.
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
return lipgloss.Color(s), nil
}
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
}