turbo-editors/turbo-corepublic Fork 0
v1.0.0
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

load.go · 325 lines · 9.3 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 18h ago1package theme
2
3import (
4 "embed"
5 "errors"
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "sort"
11 "strings"
12
13 "github.com/BurntSushi/toml"
14 "github.com/gdamore/tcell/v2"
15)
16
17//go:embed themes/*.toml
18var embeddedThemes embed.FS
19
20// embeddedDir is where the built-in theme files live inside the binary.
21const embeddedDir = "themes"
22
23// DefaultName is the theme used when the user has not chosen one.
24const DefaultName = "turbo-classic"
25
26// ErrNotFound is returned by Load when no theme of that name exists, either in
27// the user's directory or among the embedded ones.
28var ErrNotFound = errors.New("theme: not found")
29
30// maxInheritDepth stops a chain of "inherits" from looping forever.
31const maxInheritDepth = 16
32
33// retiredNames maps a name a shipped theme used to answer to onto the name it
34// answers to now.
35//
36// A theme's name is not this project's to change: it is what somebody wrote in
37// a settings file their whole team shares, and what they typed after -theme.
38// When "monochrome" gained a light counterpart, keeping the pair symmetric was
39// worth a rename and breaking those files was not, so the old name still
40// loads.
41//
42// An alias is deliberately *not* listed by Available, so the theme dialog and
43// -list-themes show each theme once, under the name it has now. What it costs
44// is that a name here can never be reused for a different theme, which is the
45// same promise the rename was made to keep.
46var retiredNames = map[string]string{
47 "monochrome": "monochrome-dark",
48}
49
50// file is the on-disk shape of a theme.
51type file struct {
52 Name string `toml:"name"`
53 Description string `toml:"description"`
54 Inherits string `toml:"inherits"`
55 Colors map[string]Entry `toml:"colors"`
56}
57
58// Load returns the theme called name, looking in userDir before the themes
59// embedded in the binary.
60//
61// A file in userDir wins over an embedded theme of the same name, so a shipped
62// theme can be overridden without being replaced. Pass "" for userDir to offer
63// the embedded themes alone, which is what a system with no configuration
64// directory gets.
65//
66// th, err := theme.Load("turbo-classic", p.ThemeDir())
67// if err != nil {
68// th = theme.Default(p.ThemeDir())
69// }
70func Load(name, userDir string) (*Theme, error) {
71 return loadNamed(name, userDir, 0)
72}
73
74// Default returns the theme used when the user has not chosen one, honouring
75// a file of that name in userDir the way Load does.
76//
77// The embedded file it reads is checked by a test, so this cannot fail in
78// practice; if it ever did, a plain white-on-black theme is returned rather
79// than nothing, because the editor must always have something to draw with.
80func Default(userDir string) *Theme {
81 th, err := Load(DefaultName, userDir)
82 if err != nil {
83 return &Theme{
84 name: DefaultName,
85 styles: map[string]tcell.Style{KeyDefault: fallbackStyle()},
86 }
87 }
88 return th
89}
90
91// Available returns the names of every theme that can be loaded, from userDir
92// and from the ones embedded in the binary, sorted and without duplicates.
93func Available(userDir string) []string {
94 seen := map[string]bool{}
95 for _, name := range embeddedNames() {
96 seen[name] = true
97 }
98 for _, name := range userNames(userDir) {
99 seen[name] = true
100 }
101
102 names := make([]string, 0, len(seen))
103 for name := range seen {
104 names = append(names, name)
105 }
106 sort.Strings(names)
107 return names
108}
109
110// LoadFile reads a theme from a TOML file on disk.
111//
112// userDir is where a theme this one inherits from is looked for, and is
113// otherwise unused.
114func LoadFile(path, userDir string) (*Theme, error) {
115 return loadFileAt(path, userDir, 0)
116}
117
118// loadFileAt is LoadFile plus the recursion depth, which must be carried
119// across every hop of an "inherits" chain for the loop guard to see it.
120func loadFileAt(path, userDir string, depth int) (*Theme, error) {
121 data, err := os.ReadFile(path)
122 if err != nil {
123 return nil, fmt.Errorf("theme: read %s: %w", path, err)
124 }
125
126 th, err := parse(data, userDir, depth)
127 if err != nil {
128 return nil, fmt.Errorf("theme: %s: %w", path, err)
129 }
130 if th.name == "" {
131 th.name = strings.TrimSuffix(filepath.Base(path), ".toml")
132 }
133 return th, nil
134}
135
136// Parse reads a theme from the content of a TOML file.
137//
138// Every colour is resolved eagerly, so a typo in a colour name is reported
139// here rather than silently drawn in the terminal's default colour halfway
140// through an editing session.
141func Parse(data []byte, userDir string) (*Theme, error) {
142 return parse(data, userDir, 0)
143}
144
145// parse is Parse plus the recursion depth, which caps chains of "inherits".
146func parse(data []byte, userDir string, depth int) (*Theme, error) {
147 var f file
148 if _, err := toml.Decode(string(data), &f); err != nil {
149 return nil, fmt.Errorf("invalid TOML: %w", err)
150 }
151
152 base, err := inheritedStyles(f.Inherits, userDir, depth)
153 if err != nil {
154 return nil, err
155 }
156
157 styles, err := resolveStyles(f.Colors, base)
158 if err != nil {
159 return nil, err
160 }
161
162 return &Theme{name: f.Name, description: f.Description, styles: styles}, nil
163}
164
165// inheritedStyles returns the resolved styles of the parent theme, or a bare
166// default when there is no parent.
167func inheritedStyles(parent, userDir string, depth int) (map[string]tcell.Style, error) {
168 if parent == "" {
169 return map[string]tcell.Style{KeyDefault: fallbackStyle()}, nil
170 }
171 if depth >= maxInheritDepth {
172 return nil, fmt.Errorf("inherits: chain deeper than %d, probably a loop", maxInheritDepth)
173 }
174
175 th, err := loadNamed(parent, userDir, depth+1)
176 if err != nil {
177 return nil, fmt.Errorf("inherits %q: %w", parent, err)
178 }
179
180 styles := make(map[string]tcell.Style, len(th.styles))
181 for key, style := range th.styles {
182 styles[key] = style
183 }
184 return styles, nil
185}
186
187// resolveStyles turns the entries of a theme file into styles, resolving
188// shallower keys first so that "syntax.keyword" can inherit from "syntax",
189// and "syntax" from "default".
190func resolveStyles(entries map[string]Entry, base map[string]tcell.Style) (map[string]tcell.Style, error) {
191 if _, ok := base[KeyDefault]; !ok {
192 base[KeyDefault] = fallbackStyle()
193 }
194
195 for _, key := range byDepth(entries) {
196 style, err := entries[key].style(lookup(base, parentKey(key)))
197 if err != nil {
198 return nil, fmt.Errorf("colors.%q: %w", key, err)
199 }
200 base[key] = style
201 }
202 return base, nil
203}
204
205// byDepth returns the keys of entries sorted by how many dots they contain,
206// then alphabetically so that the order is stable.
207func byDepth(entries map[string]Entry) []string {
208 keys := make([]string, 0, len(entries))
209 for key := range entries {
210 keys = append(keys, key)
211 }
212 sort.Slice(keys, func(i, j int) bool {
213 di, dj := strings.Count(keys[i], "."), strings.Count(keys[j], ".")
214 if di != dj {
215 return di < dj
216 }
217 return keys[i] < keys[j]
218 })
219 return keys
220}
221
222// parentKey returns the key one level up: "syntax.keyword" gives "syntax", and
223// a key with no dot gives KeyDefault.
224func parentKey(key string) string {
225 if dot := strings.LastIndexByte(key, '.'); dot >= 0 {
226 return key[:dot]
227 }
228 return KeyDefault
229}
230
231// lookup reads a key from resolved styles, walking up the dots as Style does.
232func lookup(styles map[string]tcell.Style, key string) tcell.Style {
233 for {
234 if style, ok := styles[key]; ok {
235 return style
236 }
237 dot := strings.LastIndexByte(key, '.')
238 if dot < 0 {
239 return styles[KeyDefault]
240 }
241 key = key[:dot]
242 }
243}
244
245// loadNamed is Load plus the recursion depth used by "inherits".
246func loadNamed(name, userDir string, depth int) (*Theme, error) {
247 if err := validateName(name); err != nil {
248 return nil, err
249 }
250
251 if userDir != "" {
252 path := filepath.Join(userDir, name+".toml")
253 if _, err := os.Stat(path); err == nil {
254 return loadFileAt(path, userDir, depth)
255 }
256 }
257
258 // After the user's directory, never before it: somebody who has written
259 // their own monochrome.toml gets theirs, exactly as they would for any
260 // other name a shipped theme also answers to.
261 if current, retired := retiredNames[name]; retired {
262 name = current
263 }
264
265 data, err := embeddedThemes.ReadFile(embeddedDir + "/" + name + ".toml")
266 if err != nil {
267 return nil, fmt.Errorf("%w: %q", ErrNotFound, name)
268 }
269
270 th, err := parse(data, userDir, depth)
271 if err != nil {
272 return nil, fmt.Errorf("theme: embedded %s: %w", name, err)
273 }
274 if th.name == "" {
275 th.name = name
276 }
277 return th, nil
278}
279
280// validateName rejects names that would let a theme reference escape the
281// directories themes are meant to come from.
282func validateName(name string) error {
283 if name == "" {
284 return fmt.Errorf("%w: empty name", ErrNotFound)
285 }
286 if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
287 return fmt.Errorf("%w: %q is not a plain theme name", ErrNotFound, name)
288 }
289 return nil
290}
291
292// embeddedNames lists the themes compiled into the binary.
293func embeddedNames() []string {
294 entries, err := embeddedThemes.ReadDir(embeddedDir)
295 if err != nil {
296 return nil
297 }
298 return tomlBaseNames(entries)
299}
300
301// userNames lists the themes in the user's theme directory, which may well not
302// exist.
303func userNames(userDir string) []string {
304 if userDir == "" {
305 return nil
306 }
307 entries, err := os.ReadDir(userDir)
308 if err != nil {
309 return nil
310 }
311 return tomlBaseNames(entries)
312}
313
314// tomlBaseNames returns the names of the .toml files among entries, without
315// their extension.
316func tomlBaseNames(entries []fs.DirEntry) []string {
317 var names []string
318 for _, entry := range entries {
319 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".toml") {
320 continue
321 }
322 names = append(names, strings.TrimSuffix(entry.Name(), ".toml"))
323 }
324 return names
325}