package theme import ( "embed" "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "github.com/BurntSushi/toml" "github.com/gdamore/tcell/v2" ) //go:embed themes/*.toml var embeddedThemes embed.FS // embeddedDir is where the built-in theme files live inside the binary. const embeddedDir = "themes" // DefaultName is the theme used when the user has not chosen one. const DefaultName = "turbo-classic" // ErrNotFound is returned by Load when no theme of that name exists, either in // the user's directory or among the embedded ones. var ErrNotFound = errors.New("theme: not found") // maxInheritDepth stops a chain of "inherits" from looping forever. const maxInheritDepth = 16 // retiredNames maps a name a shipped theme used to answer to onto the name it // answers to now. // // A theme's name is not this project's to change: it is what somebody wrote in // a settings file their whole team shares, and what they typed after -theme. // When "monochrome" gained a light counterpart, keeping the pair symmetric was // worth a rename and breaking those files was not, so the old name still // loads. // // An alias is deliberately *not* listed by Available, so the theme dialog and // -list-themes show each theme once, under the name it has now. What it costs // is that a name here can never be reused for a different theme, which is the // same promise the rename was made to keep. var retiredNames = map[string]string{ "monochrome": "monochrome-dark", } // file is the on-disk shape of a theme. type file struct { Name string `toml:"name"` Description string `toml:"description"` Inherits string `toml:"inherits"` Colors map[string]Entry `toml:"colors"` } // Load returns the theme called name, looking in userDir before the themes // embedded in the binary. // // A file in userDir wins over an embedded theme of the same name, so a shipped // theme can be overridden without being replaced. Pass "" for userDir to offer // the embedded themes alone, which is what a system with no configuration // directory gets. // // th, err := theme.Load("turbo-classic", p.ThemeDir()) // if err != nil { // th = theme.Default(p.ThemeDir()) // } func Load(name, userDir string) (*Theme, error) { return loadNamed(name, userDir, 0) } // Default returns the theme used when the user has not chosen one, honouring // a file of that name in userDir the way Load does. // // The embedded file it reads is checked by a test, so this cannot fail in // practice; if it ever did, a plain white-on-black theme is returned rather // than nothing, because the editor must always have something to draw with. func Default(userDir string) *Theme { th, err := Load(DefaultName, userDir) if err != nil { return &Theme{ name: DefaultName, styles: map[string]tcell.Style{KeyDefault: fallbackStyle()}, } } return th } // Available returns the names of every theme that can be loaded, from userDir // and from the ones embedded in the binary, sorted and without duplicates. func Available(userDir string) []string { seen := map[string]bool{} for _, name := range embeddedNames() { seen[name] = true } for _, name := range userNames(userDir) { seen[name] = true } names := make([]string, 0, len(seen)) for name := range seen { names = append(names, name) } sort.Strings(names) return names } // LoadFile reads a theme from a TOML file on disk. // // userDir is where a theme this one inherits from is looked for, and is // otherwise unused. func LoadFile(path, userDir string) (*Theme, error) { return loadFileAt(path, userDir, 0) } // loadFileAt is LoadFile plus the recursion depth, which must be carried // across every hop of an "inherits" chain for the loop guard to see it. func loadFileAt(path, userDir string, depth int) (*Theme, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("theme: read %s: %w", path, err) } th, err := parse(data, userDir, depth) if err != nil { return nil, fmt.Errorf("theme: %s: %w", path, err) } if th.name == "" { th.name = strings.TrimSuffix(filepath.Base(path), ".toml") } return th, nil } // Parse reads a theme from the content of a TOML file. // // Every colour is resolved eagerly, so a typo in a colour name is reported // here rather than silently drawn in the terminal's default colour halfway // through an editing session. func Parse(data []byte, userDir string) (*Theme, error) { return parse(data, userDir, 0) } // parse is Parse plus the recursion depth, which caps chains of "inherits". func parse(data []byte, userDir string, depth int) (*Theme, error) { var f file if _, err := toml.Decode(string(data), &f); err != nil { return nil, fmt.Errorf("invalid TOML: %w", err) } base, err := inheritedStyles(f.Inherits, userDir, depth) if err != nil { return nil, err } styles, err := resolveStyles(f.Colors, base) if err != nil { return nil, err } return &Theme{name: f.Name, description: f.Description, styles: styles}, nil } // inheritedStyles returns the resolved styles of the parent theme, or a bare // default when there is no parent. func inheritedStyles(parent, userDir string, depth int) (map[string]tcell.Style, error) { if parent == "" { return map[string]tcell.Style{KeyDefault: fallbackStyle()}, nil } if depth >= maxInheritDepth { return nil, fmt.Errorf("inherits: chain deeper than %d, probably a loop", maxInheritDepth) } th, err := loadNamed(parent, userDir, depth+1) if err != nil { return nil, fmt.Errorf("inherits %q: %w", parent, err) } styles := make(map[string]tcell.Style, len(th.styles)) for key, style := range th.styles { styles[key] = style } return styles, nil } // resolveStyles turns the entries of a theme file into styles, resolving // shallower keys first so that "syntax.keyword" can inherit from "syntax", // and "syntax" from "default". func resolveStyles(entries map[string]Entry, base map[string]tcell.Style) (map[string]tcell.Style, error) { if _, ok := base[KeyDefault]; !ok { base[KeyDefault] = fallbackStyle() } for _, key := range byDepth(entries) { style, err := entries[key].style(lookup(base, parentKey(key))) if err != nil { return nil, fmt.Errorf("colors.%q: %w", key, err) } base[key] = style } return base, nil } // byDepth returns the keys of entries sorted by how many dots they contain, // then alphabetically so that the order is stable. func byDepth(entries map[string]Entry) []string { keys := make([]string, 0, len(entries)) for key := range entries { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { di, dj := strings.Count(keys[i], "."), strings.Count(keys[j], ".") if di != dj { return di < dj } return keys[i] < keys[j] }) return keys } // parentKey returns the key one level up: "syntax.keyword" gives "syntax", and // a key with no dot gives KeyDefault. func parentKey(key string) string { if dot := strings.LastIndexByte(key, '.'); dot >= 0 { return key[:dot] } return KeyDefault } // lookup reads a key from resolved styles, walking up the dots as Style does. func lookup(styles map[string]tcell.Style, key string) tcell.Style { for { if style, ok := styles[key]; ok { return style } dot := strings.LastIndexByte(key, '.') if dot < 0 { return styles[KeyDefault] } key = key[:dot] } } // loadNamed is Load plus the recursion depth used by "inherits". func loadNamed(name, userDir string, depth int) (*Theme, error) { if err := validateName(name); err != nil { return nil, err } if userDir != "" { path := filepath.Join(userDir, name+".toml") if _, err := os.Stat(path); err == nil { return loadFileAt(path, userDir, depth) } } // After the user's directory, never before it: somebody who has written // their own monochrome.toml gets theirs, exactly as they would for any // other name a shipped theme also answers to. if current, retired := retiredNames[name]; retired { name = current } data, err := embeddedThemes.ReadFile(embeddedDir + "/" + name + ".toml") if err != nil { return nil, fmt.Errorf("%w: %q", ErrNotFound, name) } th, err := parse(data, userDir, depth) if err != nil { return nil, fmt.Errorf("theme: embedded %s: %w", name, err) } if th.name == "" { th.name = name } return th, nil } // validateName rejects names that would let a theme reference escape the // directories themes are meant to come from. func validateName(name string) error { if name == "" { return fmt.Errorf("%w: empty name", ErrNotFound) } if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { return fmt.Errorf("%w: %q is not a plain theme name", ErrNotFound, name) } return nil } // embeddedNames lists the themes compiled into the binary. func embeddedNames() []string { entries, err := embeddedThemes.ReadDir(embeddedDir) if err != nil { return nil } return tomlBaseNames(entries) } // userNames lists the themes in the user's theme directory, which may well not // exist. func userNames(userDir string) []string { if userDir == "" { return nil } entries, err := os.ReadDir(userDir) if err != nil { return nil } return tomlBaseNames(entries) } // tomlBaseNames returns the names of the .toml files among entries, without // their extension. func tomlBaseNames(entries []fs.DirEntry) []string { var names []string for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".toml") { continue } names = append(names, strings.TrimSuffix(entry.Name(), ".toml")) } return names }