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