1// Package theme reads a color theme: the 16 ANSI colors plus a background and
2// a foreground. It understands the config files of kitty, foot, ghostty and
3// alacritty, so the bar can look like the terminal you use.
4package theme
5
6import (
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strconv"
12 "strings"
13)
14
15// RGB is a color.
16type RGB = [3]uint8
17
18// Theme is a set of colors. A color a source does not set keeps the value of
19// the theme it was filled from (see Default and Fill).
20type Theme struct {
21 Background, Foreground RGB
22 Palette [16]RGB
23 // Source says where the colors came from, for logs.
24 Source string
25 // Files lists every file that was read, includes and named themes
26 // included, so a caller can watch them for changes.
27 Files []string
28
29 hasBG, hasFG bool
30 hasPal [16]bool
31}
32
33// Default is the built-in dark theme, used for any color a source leaves out.
34func Default() Theme {
35 return Theme{
36 Background: RGB{0x1e, 0x1e, 0x2e}, Foreground: RGB{0xcd, 0xd6, 0xf4},
37 Palette: [16]RGB{
38 {0x45, 0x47, 0x5a}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
39 {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xba, 0xc2, 0xde},
40 {0x58, 0x5b, 0x70}, {0xf3, 0x8b, 0xa8}, {0xa6, 0xe3, 0xa1}, {0xf9, 0xe2, 0xaf},
41 {0x89, 0xb4, 0xfa}, {0xf5, 0xc2, 0xe7}, {0x94, 0xe2, 0xd5}, {0xa6, 0xad, 0xc8},
42 },
43 Source: "built-in",
44 }
45}
46
47// Fill returns t with every color it does not define taken from def.
48func (t Theme) Fill(def Theme) Theme {
49 if !t.hasBG {
50 t.Background = def.Background
51 }
52 if !t.hasFG {
53 t.Foreground = def.Foreground
54 }
55 for i := range t.Palette {
56 if !t.hasPal[i] {
57 t.Palette[i] = def.Palette[i]
58 }
59 }
60 return t
61}
62
63// dedupFiles removes repeated entries, keeping the first of each.
64func dedupFiles(files []string) []string {
65 seen := make(map[string]bool, len(files))
66 out := files[:0:0]
67 for _, f := range files {
68 if !seen[f] {
69 seen[f] = true
70 out = append(out, f)
71 }
72 }
73 return out
74}
75
76// Defines reports how many of the 18 colors the source set.
77func (t Theme) Defines() int {
78 n := 0
79 if t.hasBG {
80 n++
81 }
82 if t.hasFG {
83 n++
84 }
85 for _, h := range t.hasPal {
86 if h {
87 n++
88 }
89 }
90 return n
91}
92
93func (t *Theme) setBG(c RGB) { t.Background, t.hasBG = c, true }
94func (t *Theme) setFG(c RGB) { t.Foreground, t.hasFG = c, true }
95func (t *Theme) setPal(i int, c RGB) { t.Palette[i], t.hasPal[i] = c, true }
96
97// overlay applies every color src defines on top of t.
98func (t *Theme) overlay(src Theme) {
99 t.Files = append(t.Files, src.Files...)
100 if src.hasBG {
101 t.setBG(src.Background)
102 }
103 if src.hasFG {
104 t.setFG(src.Foreground)
105 }
106 for i, h := range src.hasPal {
107 if h {
108 t.setPal(i, src.Palette[i])
109 }
110 }
111}
112
113var hexRe = regexp.MustCompile(`^(?:#|0x|0X)?([0-9a-fA-F]{6})$`)
114
115// parseColor accepts #rrggbb, rrggbb and 0xrrggbb, with optional quotes.
116func parseColor(s string) (RGB, bool) {
117 s = strings.Trim(strings.TrimSpace(s), `"'`)
118 m := hexRe.FindStringSubmatch(s)
119 if m == nil {
120 return RGB{}, false
121 }
122 v, _ := strconv.ParseUint(m[1], 16, 32)
123 return RGB{uint8(v >> 16), uint8(v >> 8), uint8(v)}, true
124}
125
126// expand resolves a leading ~ and, for a relative path, joins it to dir.
127func expand(p, dir string) string {
128 p = strings.Trim(strings.TrimSpace(p), `"'`)
129 if p == "~" || strings.HasPrefix(p, "~/") {
130 home, _ := os.UserHomeDir()
131 p = filepath.Join(home, strings.TrimPrefix(p, "~"))
132 }
133 if !filepath.IsAbs(p) {
134 p = filepath.Join(dir, p)
135 }
136 return p
137}
138
139// maxDepth bounds include chains, so a loop cannot hang the bar.
140const maxDepth = 8
141
142// Load reads the theme file at path. The format is recognized from the file:
143// a .toml file is alacritty, a file with "palette = N=#rrggbb" or "theme =" lines is
144// ghostty, one with a [colors] section is foot, and anything else is kitty.
145func Load(path string) (Theme, error) {
146 f, err := detectFormat(path)
147 if err != nil {
148 return Theme{}, err
149 }
150 t, err := f.load(path)
151 if err != nil {
152 return Theme{}, err
153 }
154 if t.Defines() == 0 {
155 return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
156 }
157 t.Source = fmt.Sprintf("%s (%s)", path, f.name)
158 t.Files = dedupFiles(t.Files)
159 return t, nil
160}
161
162var (
163 ghosttyHint = regexp.MustCompile(`(?m)^\s*(palette\s*=\s*\d+\s*=|theme\s*=)`)
164 footHint = regexp.MustCompile(`(?m)^\s*\[colors`)
165)
166
167func detectFormat(path string) (format, error) {
168 data, err := os.ReadFile(path)
169 if err != nil {
170 return format{}, fmt.Errorf("theme: %w", err)
171 }
172 switch {
173 case strings.HasSuffix(path, ".toml"):
174 return formats["alacritty"], nil
175 case ghosttyHint.Match(data):
176 return formats["ghostty"], nil
177 case footHint.Match(data):
178 return formats["foot"], nil
179 }
180 return formats["kitty"], nil
181}
182
183type format struct {
184 name string
185 load func(path string) (Theme, error)
186}
187
188var formats = map[string]format{}
189
190func init() {
191 formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
192 formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
193 formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }}
194 formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
195}
196
197// Resolve turns the bar.theme setting into a complete theme. spec is "auto"
198// (the terminal in use) or the path of a theme or config file; a relative
199// path is taken from baseDir. Colors the source leaves out come from Default.
200func Resolve(spec, baseDir string, env Env) (Theme, error) {
201 def := Default()
202 var t Theme
203 var err error
204 if spec == "auto" {
205 t, err = Auto(env)
206 } else {
207 t, err = Load(expand(spec, baseDir))
208 }
209 if err != nil {
210 return def, err
211 }
212 t = t.Fill(def)
213 t.Files = dedupFiles(t.Files)
214 return t, nil
215}
216
217// Mix returns a blended with b: t=0 is a, t=1 is b.
218func Mix(a, b RGB, t float64) RGB {
219 var o RGB
220 for i := range o {
221 o[i] = uint8(float64(a[i]) + (float64(b[i])-float64(a[i]))*t + 0.5)
222 }
223 return o
224}
225
226// Hex formats a color as #rrggbb.
227func Hex(c RGB) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
228
229// EnvVars returns the colors as environment entries for scripts:
230// NEFERBAR_BACKGROUND, NEFERBAR_FOREGROUND, NEFERBAR_ACCENT and
231// NEFERBAR_COLOR0..15, each #rrggbb. bg and fg are the colors the bar really
232// uses; accent is the number of the palette color chosen for highlights.
233func (t Theme) EnvVars(bg, fg RGB, accent int) []string {
234 out := []string{"NEFERBAR_BACKGROUND=" + Hex(bg), "NEFERBAR_FOREGROUND=" + Hex(fg),
235 "NEFERBAR_ACCENT=" + Hex(t.Palette[accent&15])}
236 for i, c := range t.Palette {
237 out = append(out, fmt.Sprintf("NEFERBAR_COLOR%d=%s", i, Hex(c)))
238 }
239 return out
240}
241
242// Equal reports whether two themes have the same colors.
243func (t Theme) Equal(o Theme) bool {
244 return t.Background == o.Background && t.Foreground == o.Foreground && t.Palette == o.Palette
245}