1package theme
2
3import (
4 "bufio"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9
10 "github.com/BurntSushi/toml"
11)
12
13// lines returns the non-empty lines of a file, without trailing whitespace.
14func lines(path string) ([]string, error) {
15 f, err := os.Open(path)
16 if err != nil {
17 return nil, err
18 }
19 defer f.Close()
20 var out []string
21 sc := bufio.NewScanner(f)
22 for sc.Scan() {
23 if l := strings.TrimSpace(sc.Text()); l != "" {
24 out = append(out, l)
25 }
26 }
27 return out, sc.Err()
28}
29
30// splitFirstWord splits "key value" at the first run of spaces or tabs.
31func splitFirstWord(l string) (key, val string) {
32 i := strings.IndexAny(l, " \t")
33 if i < 0 {
34 return l, ""
35 }
36 return l[:i], strings.TrimSpace(l[i:])
37}
38
39// ---- kitty: "key value" lines, "include file" ------------------------------
40
41func loadKitty(path string, depth int) (Theme, error) {
42 var t Theme
43 ls, err := lines(path)
44 if err != nil {
45 return t, err
46 }
47 t.Files = append(t.Files, path)
48 for _, l := range ls {
49 if strings.HasPrefix(l, "#") {
50 continue
51 }
52 key, val := splitFirstWord(l)
53 switch {
54 case key == "include" && depth < maxDepth:
55 inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
56 if err == nil { // a missing include is skipped, as kitty would warn
57 t.overlay(inc)
58 }
59 case key == "background":
60 if c, ok := parseColor(val); ok {
61 t.setBG(c)
62 }
63 case key == "foreground":
64 if c, ok := parseColor(val); ok {
65 t.setFG(c)
66 }
67 case strings.HasPrefix(key, "color"):
68 if n, err := strconv.Atoi(strings.TrimPrefix(key, "color")); err == nil && n >= 0 && n < 16 {
69 if c, ok := parseColor(val); ok {
70 t.setPal(n, c)
71 }
72 }
73 }
74 }
75 return t, nil
76}
77
78// ---- foot: ini, [colors] and [colors-dark], "include=file" -----------------
79
80// footLayers reads one foot file. [colors-dark] wins over the legacy [colors],
81// as in foot, and an include is merged layer by layer, as if its text were
82// pasted where the include line is.
83func footLayers(path string, depth int) (colors, dark Theme, err error) {
84 ls, err := lines(path)
85 if err != nil {
86 return colors, dark, err
87 }
88 colors.Files = append(colors.Files, path)
89 section := ""
90 for _, l := range ls {
91 if strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
92 continue
93 }
94 if strings.HasPrefix(l, "[") {
95 section = strings.Trim(l, "[] ")
96 continue
97 }
98 key, val, ok := strings.Cut(l, "=")
99 if !ok {
100 continue
101 }
102 key, val = strings.TrimSpace(key), strings.TrimSpace(val)
103 if key == "include" {
104 if depth < maxDepth {
105 if c, d, err := footLayers(expand(val, filepath.Dir(path)), depth+1); err == nil {
106 colors.overlay(c)
107 dark.overlay(d)
108 }
109 }
110 continue
111 }
112 var dst *Theme
113 switch section {
114 case "colors":
115 dst = &colors
116 case "colors-dark":
117 dst = &dark
118 default:
119 continue
120 }
121 val, _, _ = strings.Cut(val, " ") // a trailing "# comment"
122 c, ok := parseColor(val)
123 if !ok {
124 continue
125 }
126 switch {
127 case key == "background":
128 dst.setBG(c)
129 case key == "foreground":
130 dst.setFG(c)
131 case strings.HasPrefix(key, "regular"):
132 if n, err := strconv.Atoi(key[len("regular"):]); err == nil && n >= 0 && n < 8 {
133 dst.setPal(n, c)
134 }
135 case strings.HasPrefix(key, "bright"):
136 if n, err := strconv.Atoi(key[len("bright"):]); err == nil && n >= 0 && n < 8 {
137 dst.setPal(8+n, c)
138 }
139 }
140 }
141 return colors, dark, nil
142}
143
144func loadFoot(path string, depth int) (Theme, error) {
145 colors, dark, err := footLayers(path, depth)
146 if err != nil {
147 return Theme{}, err
148 }
149 var t Theme
150 t.overlay(colors)
151 t.overlay(dark)
152 return t, nil
153}
154
155// ---- ghostty: "key = value", "palette = N=#rrggbb", "theme = name" ---------
156
157func ghosttyDirs(config string) []string {
158 home, _ := os.UserHomeDir()
159 return []string{
160 filepath.Join(filepath.Dir(config), "themes"),
161 filepath.Join(home, ".local", "share", "ghostty", "themes"),
162 "/usr/share/ghostty/themes",
163 "/usr/local/share/ghostty/themes",
164 }
165}
166
167func loadGhostty(path string, depth int) (Theme, error) {
168 var own Theme
169 themeName := ""
170 ls, err := lines(path)
171 if err != nil {
172 return own, err
173 }
174 own.Files = append(own.Files, path)
175 for _, l := range ls {
176 if strings.HasPrefix(l, "#") {
177 continue
178 }
179 key, val, ok := strings.Cut(l, "=")
180 if !ok {
181 continue
182 }
183 key, val = strings.TrimSpace(key), strings.TrimSpace(val)
184 switch key {
185 case "theme":
186 themeName = strings.Trim(val, `"'`)
187 case "background":
188 if c, ok := parseColor(val); ok {
189 own.setBG(c)
190 }
191 case "foreground":
192 if c, ok := parseColor(val); ok {
193 own.setFG(c)
194 }
195 case "palette":
196 idx, hex, ok := strings.Cut(val, "=")
197 if !ok {
198 continue
199 }
200 if n, err := strconv.Atoi(strings.TrimSpace(idx)); err == nil && n >= 0 && n < 16 {
201 if c, ok := parseColor(hex); ok {
202 own.setPal(n, c)
203 }
204 }
205 }
206 }
207 // The named theme is applied first; the config's own colors win over it.
208 var t Theme
209 if themeName != "" {
210 if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth {
211 if th, err := loadGhostty(file, depth+1); err == nil {
212 t.overlay(th)
213 }
214 }
215 }
216 t.overlay(own)
217 return t, nil
218}
219
220// findGhosttyTheme resolves "dark:x,light:y" to x and looks the name up.
221func findGhosttyTheme(config, name string) string {
222 for _, part := range strings.Split(name, ",") {
223 if k, v, ok := strings.Cut(part, ":"); ok {
224 if strings.TrimSpace(k) == "dark" {
225 name = strings.TrimSpace(v)
226 }
227 }
228 }
229 if k, v, ok := strings.Cut(name, ":"); ok && (k == "dark" || k == "light") {
230 name = v
231 }
232 if filepath.IsAbs(name) {
233 return name
234 }
235 for _, dir := range ghosttyDirs(config) {
236 if p := filepath.Join(dir, name); fileExists(p) {
237 return p
238 }
239 }
240 return ""
241}
242
243// ---- alacritty: toml, import = [files], [colors.*] -------------------------
244
245type alacrittyFile struct {
246 Import []string `toml:"import"` // older configs
247 General struct {
248 Import []string `toml:"import"`
249 } `toml:"general"`
250 Colors struct {
251 Primary struct {
252 Background string `toml:"background"`
253 Foreground string `toml:"foreground"`
254 } `toml:"primary"`
255 Normal map[string]string `toml:"normal"`
256 Bright map[string]string `toml:"bright"`
257 } `toml:"colors"`
258}
259
260var alacrittyNames = [8]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}
261
262func loadAlacritty(path string, depth int) (Theme, error) {
263 var t Theme
264 var f alacrittyFile
265 if _, err := toml.DecodeFile(path, &f); err != nil {
266 return t, err
267 }
268 t.Files = append(t.Files, path)
269 // Imports come first, so the file's own colors win over them.
270 if depth < maxDepth {
271 for _, imp := range append(f.General.Import, f.Import...) {
272 if inc, err := loadAlacritty(expand(imp, filepath.Dir(path)), depth+1); err == nil {
273 t.overlay(inc)
274 }
275 }
276 }
277 if c, ok := parseColor(f.Colors.Primary.Background); ok {
278 t.setBG(c)
279 }
280 if c, ok := parseColor(f.Colors.Primary.Foreground); ok {
281 t.setFG(c)
282 }
283 for i, name := range alacrittyNames {
284 if c, ok := parseColor(f.Colors.Normal[name]); ok {
285 t.setPal(i, c)
286 }
287 if c, ok := parseColor(f.Colors.Bright[name]); ok {
288 t.setPal(8+i, c)
289 }
290 }
291 return t, nil
292}
293
294func fileExists(p string) bool {
295 st, err := os.Stat(p)
296 return err == nil && !st.IsDir()
297}