// Package theme handles loading and parsing color themes from TOML files. package theme import ( "bufio" "cmp" "embed" "io" "os" "path/filepath" "slices" "strings" "github.com/bjarneo/cliamp/internal/appdir" ) //go:embed themes/*.toml var builtinThemes embed.FS // DefaultName is the display name for the built-in ANSI fallback theme. const DefaultName = "Default - Terminal colors" // Theme holds a named color scheme with hex color values. type Theme struct { Name string Accent string // hex BrightFG string FG string Green string Yellow string Red string } // IsDefault returns true if this is the sentinel default theme (no hex values). func (t Theme) IsDefault() bool { return t.Accent == "" && t.Green == "" && t.BrightFG == "" } // Default returns a sentinel "Default" theme with empty hex values, // signaling that ANSI fallback colors should be used. func Default() Theme { return Theme{Name: DefaultName} } // Parse reads flat TOML key=value lines from r and returns a Theme. // Uses the same manual parsing approach as config/config.go. func Parse(name string, r io.Reader) (Theme, error) { t := Theme{Name: name} scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) val = strings.Trim(val, `"'`) switch key { case "accent": t.Accent = val case "bright_fg": t.BrightFG = val case "fg": t.FG = val case "red": t.Red = val case "yellow": t.Yellow = val case "green": t.Green = val } } return t, scanner.Err() } // LoadAll loads built-in themes and user custom themes from // ~/.config/cliamp/themes/*.toml. User themes override built-in // themes with the same name. Returns a sorted list. func LoadAll() []Theme { themes := make(map[string]Theme) // Load embedded built-in themes (lower priority). loadBuiltin(themes) // Load user custom themes (override built-in if same name). dir, err := appdir.Dir() if err == nil { loadUserDir(filepath.Join(dir, "themes"), themes) } // Sort by name. result := make([]Theme, 0, len(themes)) for _, t := range themes { result = append(result, t) } slices.SortFunc(result, func(a, b Theme) int { return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) }) return result } // loadBuiltin parses the embedded theme TOML files. func loadBuiltin(themes map[string]Theme) { entries, err := builtinThemes.ReadDir("themes") if err != nil { return } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { continue } name := strings.TrimSuffix(e.Name(), ".toml") f, err := builtinThemes.Open("themes/" + e.Name()) if err != nil { continue } t, err := Parse(name, f) f.Close() if err != nil { continue } themes[strings.ToLower(name)] = t } } // loadUserDir loads themes from ~/.config/cliamp/themes/*.toml. func loadUserDir(dir string, themes map[string]Theme) { entries, err := os.ReadDir(dir) if err != nil { return } for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") { continue } name := strings.TrimSuffix(e.Name(), ".toml") path := filepath.Join(dir, e.Name()) f, err := os.Open(path) if err != nil { continue } t, err := Parse(name, f) f.Close() if err != nil { continue } themes[strings.ToLower(name)] = t } }