detached b36cc68

feat(theme): read kitty, foot, ghostty and alacritty themes, with auto detection

commit
b36cc688ef949b1fd7a30625f9a737356adb3e07
author
brice · · 2026-10-05 15:42 UTC
parent
397c5b9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +818 -0
A internal/theme/auto.go +95 -0
@@ -0,0 +1,95 @@
1+package theme
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// terminal describes where one terminal keeps its config.
10+type terminal struct {
11+ name string
12+ format string
13+ rel string // path under the config directory
14+ match []string // substrings of $TERMINAL or a desktop-file id
15+}
16+
17+// terminals is the order used when nothing says which one is in use.
18+var terminals = []terminal{
19+ {"kitty", "kitty", "kitty/kitty.conf", []string{"kitty"}},
20+ {"ghostty", "ghostty", "ghostty/config", []string{"ghostty"}},
21+ {"foot", "foot", "foot/foot.ini", []string{"foot"}},
22+ {"alacritty", "alacritty", "alacritty/alacritty.toml", []string{"alacritty"}},
23+}
24+
25+// Env is how Auto reads its surroundings, so tests can fake them.
26+type Env struct {
27+ Getenv func(string) string
28+ ConfigDir string // $XDG_CONFIG_HOME or ~/.config
29+}
30+
31+// SystemEnv is the real environment.
32+func SystemEnv() Env {
33+ dir := os.Getenv("XDG_CONFIG_HOME")
34+ if dir == "" {
35+ home, _ := os.UserHomeDir()
36+ dir = filepath.Join(home, ".config")
37+ }
38+ return Env{Getenv: os.Getenv, ConfigDir: dir}
39+}
40+
41+// preferred names the terminal the user says they use, or "". It looks at
42+// $TERMINAL, then at the first entry of xdg-terminals.list (the file read by
43+// xdg-terminal-exec).
44+func (e Env) preferred() (string, string) {
45+ if v := e.Getenv("TERMINAL"); v != "" {
46+ return filepath.Base(strings.Fields(v)[0]), "$TERMINAL"
47+ }
48+ data, err := os.ReadFile(filepath.Join(e.ConfigDir, "xdg-terminals.list"))
49+ if err == nil {
50+ for _, l := range strings.Split(string(data), "\n") {
51+ if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
52+ return l, "xdg-terminals.list"
53+ }
54+ }
55+ }
56+ return "", ""
57+}
58+
59+// Auto reads the theme of the terminal in use: the one named by $TERMINAL or
60+// xdg-terminals.list when its config exists, otherwise the first of kitty,
61+// ghostty, foot and alacritty that has a config with colors in it.
62+func Auto(e Env) (Theme, error) {
63+ var order []terminal
64+ why := "first terminal config found"
65+ if want, src := e.preferred(); want != "" {
66+ for _, t := range terminals {
67+ for _, m := range t.match {
68+ if strings.Contains(strings.ToLower(want), m) {
69+ order = append(order, t)
70+ }
71+ }
72+ }
73+ if len(order) > 0 {
74+ why = src + " says " + want
75+ }
76+ }
77+ for _, t := range terminals {
78+ order = append(order, t)
79+ }
80+ var tried []string
81+ for _, t := range order {
82+ path := filepath.Join(e.ConfigDir, t.rel)
83+ tried = append(tried, path)
84+ if !fileExists(path) {
85+ continue
86+ }
87+ th, err := formats[t.format].load(path)
88+ if err != nil || th.Defines() == 0 {
89+ continue
90+ }
91+ th.Source = t.name + " (" + path + "): " + why
92+ return th, nil
93+ }
94+ return Theme{}, noTheme(tried)
95+}
@@ -0,0 +1,95 @@
1+package theme
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+// terminal describes where one terminal keeps its config.
10+type terminal struct {
11+ name string
12+ format string
13+ rel string // path under the config directory
14+ match []string // substrings of $TERMINAL or a desktop-file id
15+}
16+
17+// terminals is the order used when nothing says which one is in use.
18+var terminals = []terminal{
19+ {"kitty", "kitty", "kitty/kitty.conf", []string{"kitty"}},
20+ {"ghostty", "ghostty", "ghostty/config", []string{"ghostty"}},
21+ {"foot", "foot", "foot/foot.ini", []string{"foot"}},
22+ {"alacritty", "alacritty", "alacritty/alacritty.toml", []string{"alacritty"}},
23+}
24+
25+// Env is how Auto reads its surroundings, so tests can fake them.
26+type Env struct {
27+ Getenv func(string) string
28+ ConfigDir string // $XDG_CONFIG_HOME or ~/.config
29+}
30+
31+// SystemEnv is the real environment.
32+func SystemEnv() Env {
33+ dir := os.Getenv("XDG_CONFIG_HOME")
34+ if dir == "" {
35+ home, _ := os.UserHomeDir()
36+ dir = filepath.Join(home, ".config")
37+ }
38+ return Env{Getenv: os.Getenv, ConfigDir: dir}
39+}
40+
41+// preferred names the terminal the user says they use, or "". It looks at
42+// $TERMINAL, then at the first entry of xdg-terminals.list (the file read by
43+// xdg-terminal-exec).
44+func (e Env) preferred() (string, string) {
45+ if v := e.Getenv("TERMINAL"); v != "" {
46+ return filepath.Base(strings.Fields(v)[0]), "$TERMINAL"
47+ }
48+ data, err := os.ReadFile(filepath.Join(e.ConfigDir, "xdg-terminals.list"))
49+ if err == nil {
50+ for _, l := range strings.Split(string(data), "\n") {
51+ if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
52+ return l, "xdg-terminals.list"
53+ }
54+ }
55+ }
56+ return "", ""
57+}
58+
59+// Auto reads the theme of the terminal in use: the one named by $TERMINAL or
60+// xdg-terminals.list when its config exists, otherwise the first of kitty,
61+// ghostty, foot and alacritty that has a config with colors in it.
62+func Auto(e Env) (Theme, error) {
63+ var order []terminal
64+ why := "first terminal config found"
65+ if want, src := e.preferred(); want != "" {
66+ for _, t := range terminals {
67+ for _, m := range t.match {
68+ if strings.Contains(strings.ToLower(want), m) {
69+ order = append(order, t)
70+ }
71+ }
72+ }
73+ if len(order) > 0 {
74+ why = src + " says " + want
75+ }
76+ }
77+ for _, t := range terminals {
78+ order = append(order, t)
79+ }
80+ var tried []string
81+ for _, t := range order {
82+ path := filepath.Join(e.ConfigDir, t.rel)
83+ tried = append(tried, path)
84+ if !fileExists(path) {
85+ continue
86+ }
87+ th, err := formats[t.format].load(path)
88+ if err != nil || th.Defines() == 0 {
89+ continue
90+ }
91+ th.Source = t.name + " (" + path + "): " + why
92+ return th, nil
93+ }
94+ return Theme{}, noTheme(tried)
95+}
A internal/theme/formats.go +297 -0
@@ -0,0 +1,297 @@
1+package theme
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/BurntSushi/toml"
13+)
14+
15+// lines returns the non-empty lines of a file, without trailing whitespace.
16+func lines(path string) ([]string, error) {
17+ f, err := os.Open(path)
18+ if err != nil {
19+ return nil, err
20+ }
21+ defer f.Close()
22+ var out []string
23+ sc := bufio.NewScanner(f)
24+ for sc.Scan() {
25+ if l := strings.TrimSpace(sc.Text()); l != "" {
26+ out = append(out, l)
27+ }
28+ }
29+ return out, sc.Err()
30+}
31+
32+// ---- kitty: "key value" lines, "include file" ------------------------------
33+
34+func loadKitty(path string, depth int) (Theme, error) {
35+ var t Theme
36+ ls, err := lines(path)
37+ if err != nil {
38+ return t, err
39+ }
40+ t.Files = append(t.Files, path)
41+ for _, l := range ls {
42+ if strings.HasPrefix(l, "#") {
43+ continue
44+ }
45+ key, val, _ := strings.Cut(l, " ")
46+ val = strings.TrimSpace(val)
47+ switch {
48+ case key == "include" && depth < maxDepth:
49+ inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
50+ if err == nil { // a missing include is skipped, as kitty would warn
51+ t.overlay(inc)
52+ }
53+ case key == "background":
54+ if c, ok := parseColor(val); ok {
55+ t.setBG(c)
56+ }
57+ case key == "foreground":
58+ if c, ok := parseColor(val); ok {
59+ t.setFG(c)
60+ }
61+ case strings.HasPrefix(key, "color"):
62+ if n, err := strconv.Atoi(strings.TrimPrefix(key, "color")); err == nil && n >= 0 && n < 16 {
63+ if c, ok := parseColor(val); ok {
64+ t.setPal(n, c)
65+ }
66+ }
67+ }
68+ }
69+ return t, nil
70+}
71+
72+// ---- foot: ini, [colors] and [colors-dark], "include=file" -----------------
73+
74+// footLayers reads one foot file. [colors-dark] wins over the legacy [colors],
75+// as in foot, and an include is merged layer by layer, as if its text were
76+// pasted where the include line is.
77+func footLayers(path string, depth int) (colors, dark Theme, err error) {
78+ ls, err := lines(path)
79+ if err != nil {
80+ return colors, dark, err
81+ }
82+ colors.Files = append(colors.Files, path)
83+ section := ""
84+ for _, l := range ls {
85+ if strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
86+ continue
87+ }
88+ if strings.HasPrefix(l, "[") {
89+ section = strings.Trim(l, "[] ")
90+ continue
91+ }
92+ key, val, ok := strings.Cut(l, "=")
93+ if !ok {
94+ continue
95+ }
96+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
97+ if key == "include" {
98+ if depth < maxDepth {
99+ if c, d, err := footLayers(expand(val, filepath.Dir(path)), depth+1); err == nil {
100+ colors.overlay(c)
101+ dark.overlay(d)
102+ }
103+ }
104+ continue
105+ }
106+ var dst *Theme
107+ switch section {
108+ case "colors":
109+ dst = &colors
110+ case "colors-dark":
111+ dst = &dark
112+ default:
113+ continue
114+ }
115+ val, _, _ = strings.Cut(val, " ") // a trailing "# comment"
116+ c, ok := parseColor(val)
117+ if !ok {
118+ continue
119+ }
120+ switch {
121+ case key == "background":
122+ dst.setBG(c)
123+ case key == "foreground":
124+ dst.setFG(c)
125+ case strings.HasPrefix(key, "regular"):
126+ if n, err := strconv.Atoi(key[len("regular"):]); err == nil && n >= 0 && n < 8 {
127+ dst.setPal(n, c)
128+ }
129+ case strings.HasPrefix(key, "bright"):
130+ if n, err := strconv.Atoi(key[len("bright"):]); err == nil && n >= 0 && n < 8 {
131+ dst.setPal(8+n, c)
132+ }
133+ }
134+ }
135+ return colors, dark, nil
136+}
137+
138+func loadFoot(path string, depth int) (Theme, error) {
139+ colors, dark, err := footLayers(path, depth)
140+ if err != nil {
141+ return Theme{}, err
142+ }
143+ var t Theme
144+ t.overlay(colors)
145+ t.overlay(dark)
146+ return t, nil
147+}
148+
149+// ---- ghostty: "key = value", "palette = N=#rrggbb", "theme = name" ---------
150+
151+func ghosttyDirs(config string) []string {
152+ home, _ := os.UserHomeDir()
153+ return []string{
154+ filepath.Join(filepath.Dir(config), "themes"),
155+ filepath.Join(home, ".local", "share", "ghostty", "themes"),
156+ "/usr/share/ghostty/themes",
157+ "/usr/local/share/ghostty/themes",
158+ }
159+}
160+
161+func loadGhostty(path string) (Theme, error) {
162+ var own Theme
163+ themeName := ""
164+ ls, err := lines(path)
165+ if err != nil {
166+ return own, err
167+ }
168+ own.Files = append(own.Files, path)
169+ for _, l := range ls {
170+ if strings.HasPrefix(l, "#") {
171+ continue
172+ }
173+ key, val, ok := strings.Cut(l, "=")
174+ if !ok {
175+ continue
176+ }
177+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
178+ switch key {
179+ case "theme":
180+ themeName = strings.Trim(val, `"'`)
181+ case "background":
182+ if c, ok := parseColor(val); ok {
183+ own.setBG(c)
184+ }
185+ case "foreground":
186+ if c, ok := parseColor(val); ok {
187+ own.setFG(c)
188+ }
189+ case "palette":
190+ idx, hex, ok := strings.Cut(val, "=")
191+ if !ok {
192+ continue
193+ }
194+ if n, err := strconv.Atoi(strings.TrimSpace(idx)); err == nil && n >= 0 && n < 16 {
195+ if c, ok := parseColor(hex); ok {
196+ own.setPal(n, c)
197+ }
198+ }
199+ }
200+ }
201+ // The named theme is applied first; the config's own colors win over it.
202+ var t Theme
203+ if themeName != "" {
204+ if file := findGhosttyTheme(path, themeName); file != "" {
205+ if th, err := loadGhostty(file); err == nil {
206+ t.overlay(th)
207+ }
208+ }
209+ }
210+ t.overlay(own)
211+ return t, nil
212+}
213+
214+// findGhosttyTheme resolves "dark:x,light:y" to x and looks the name up.
215+func findGhosttyTheme(config, name string) string {
216+ for _, part := range strings.Split(name, ",") {
217+ if k, v, ok := strings.Cut(part, ":"); ok {
218+ if strings.TrimSpace(k) == "dark" {
219+ name = strings.TrimSpace(v)
220+ }
221+ }
222+ }
223+ if k, v, ok := strings.Cut(name, ":"); ok && (k == "dark" || k == "light") {
224+ name = v
225+ }
226+ if filepath.IsAbs(name) {
227+ return name
228+ }
229+ for _, dir := range ghosttyDirs(config) {
230+ if p := filepath.Join(dir, name); fileExists(p) {
231+ return p
232+ }
233+ }
234+ return ""
235+}
236+
237+// ---- alacritty: toml, import = [files], [colors.*] -------------------------
238+
239+type alacrittyFile struct {
240+ Import []string `toml:"import"` // older configs
241+ General struct {
242+ Import []string `toml:"import"`
243+ } `toml:"general"`
244+ Colors struct {
245+ Primary struct {
246+ Background string `toml:"background"`
247+ Foreground string `toml:"foreground"`
248+ } `toml:"primary"`
249+ Normal map[string]string `toml:"normal"`
250+ Bright map[string]string `toml:"bright"`
251+ } `toml:"colors"`
252+}
253+
254+var alacrittyNames = [8]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}
255+
256+func loadAlacritty(path string, depth int) (Theme, error) {
257+ var t Theme
258+ var f alacrittyFile
259+ if _, err := toml.DecodeFile(path, &f); err != nil {
260+ return t, err
261+ }
262+ t.Files = append(t.Files, path)
263+ // Imports come first, so the file's own colors win over them.
264+ if depth < maxDepth {
265+ for _, imp := range append(f.General.Import, f.Import...) {
266+ if inc, err := loadAlacritty(expand(imp, filepath.Dir(path)), depth+1); err == nil {
267+ t.overlay(inc)
268+ }
269+ }
270+ }
271+ if c, ok := parseColor(f.Colors.Primary.Background); ok {
272+ t.setBG(c)
273+ }
274+ if c, ok := parseColor(f.Colors.Primary.Foreground); ok {
275+ t.setFG(c)
276+ }
277+ for i, name := range alacrittyNames {
278+ if c, ok := parseColor(f.Colors.Normal[name]); ok {
279+ t.setPal(i, c)
280+ }
281+ if c, ok := parseColor(f.Colors.Bright[name]); ok {
282+ t.setPal(8+i, c)
283+ }
284+ }
285+ return t, nil
286+}
287+
288+func fileExists(p string) bool {
289+ st, err := os.Stat(p)
290+ return err == nil && !st.IsDir()
291+}
292+
293+var errNoTerminal = errors.New("theme: no terminal config found")
294+
295+func noTheme(tried []string) error {
296+ return fmt.Errorf("%w (looked for %s)", errNoTerminal, strings.Join(tried, ", "))
297+}
@@ -0,0 +1,297 @@
1+package theme
2+
3+import (
4+ "bufio"
5+ "errors"
6+ "fmt"
7+ "os"
8+ "path/filepath"
9+ "strconv"
10+ "strings"
11+
12+ "github.com/BurntSushi/toml"
13+)
14+
15+// lines returns the non-empty lines of a file, without trailing whitespace.
16+func lines(path string) ([]string, error) {
17+ f, err := os.Open(path)
18+ if err != nil {
19+ return nil, err
20+ }
21+ defer f.Close()
22+ var out []string
23+ sc := bufio.NewScanner(f)
24+ for sc.Scan() {
25+ if l := strings.TrimSpace(sc.Text()); l != "" {
26+ out = append(out, l)
27+ }
28+ }
29+ return out, sc.Err()
30+}
31+
32+// ---- kitty: "key value" lines, "include file" ------------------------------
33+
34+func loadKitty(path string, depth int) (Theme, error) {
35+ var t Theme
36+ ls, err := lines(path)
37+ if err != nil {
38+ return t, err
39+ }
40+ t.Files = append(t.Files, path)
41+ for _, l := range ls {
42+ if strings.HasPrefix(l, "#") {
43+ continue
44+ }
45+ key, val, _ := strings.Cut(l, " ")
46+ val = strings.TrimSpace(val)
47+ switch {
48+ case key == "include" && depth < maxDepth:
49+ inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
50+ if err == nil { // a missing include is skipped, as kitty would warn
51+ t.overlay(inc)
52+ }
53+ case key == "background":
54+ if c, ok := parseColor(val); ok {
55+ t.setBG(c)
56+ }
57+ case key == "foreground":
58+ if c, ok := parseColor(val); ok {
59+ t.setFG(c)
60+ }
61+ case strings.HasPrefix(key, "color"):
62+ if n, err := strconv.Atoi(strings.TrimPrefix(key, "color")); err == nil && n >= 0 && n < 16 {
63+ if c, ok := parseColor(val); ok {
64+ t.setPal(n, c)
65+ }
66+ }
67+ }
68+ }
69+ return t, nil
70+}
71+
72+// ---- foot: ini, [colors] and [colors-dark], "include=file" -----------------
73+
74+// footLayers reads one foot file. [colors-dark] wins over the legacy [colors],
75+// as in foot, and an include is merged layer by layer, as if its text were
76+// pasted where the include line is.
77+func footLayers(path string, depth int) (colors, dark Theme, err error) {
78+ ls, err := lines(path)
79+ if err != nil {
80+ return colors, dark, err
81+ }
82+ colors.Files = append(colors.Files, path)
83+ section := ""
84+ for _, l := range ls {
85+ if strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") {
86+ continue
87+ }
88+ if strings.HasPrefix(l, "[") {
89+ section = strings.Trim(l, "[] ")
90+ continue
91+ }
92+ key, val, ok := strings.Cut(l, "=")
93+ if !ok {
94+ continue
95+ }
96+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
97+ if key == "include" {
98+ if depth < maxDepth {
99+ if c, d, err := footLayers(expand(val, filepath.Dir(path)), depth+1); err == nil {
100+ colors.overlay(c)
101+ dark.overlay(d)
102+ }
103+ }
104+ continue
105+ }
106+ var dst *Theme
107+ switch section {
108+ case "colors":
109+ dst = &colors
110+ case "colors-dark":
111+ dst = &dark
112+ default:
113+ continue
114+ }
115+ val, _, _ = strings.Cut(val, " ") // a trailing "# comment"
116+ c, ok := parseColor(val)
117+ if !ok {
118+ continue
119+ }
120+ switch {
121+ case key == "background":
122+ dst.setBG(c)
123+ case key == "foreground":
124+ dst.setFG(c)
125+ case strings.HasPrefix(key, "regular"):
126+ if n, err := strconv.Atoi(key[len("regular"):]); err == nil && n >= 0 && n < 8 {
127+ dst.setPal(n, c)
128+ }
129+ case strings.HasPrefix(key, "bright"):
130+ if n, err := strconv.Atoi(key[len("bright"):]); err == nil && n >= 0 && n < 8 {
131+ dst.setPal(8+n, c)
132+ }
133+ }
134+ }
135+ return colors, dark, nil
136+}
137+
138+func loadFoot(path string, depth int) (Theme, error) {
139+ colors, dark, err := footLayers(path, depth)
140+ if err != nil {
141+ return Theme{}, err
142+ }
143+ var t Theme
144+ t.overlay(colors)
145+ t.overlay(dark)
146+ return t, nil
147+}
148+
149+// ---- ghostty: "key = value", "palette = N=#rrggbb", "theme = name" ---------
150+
151+func ghosttyDirs(config string) []string {
152+ home, _ := os.UserHomeDir()
153+ return []string{
154+ filepath.Join(filepath.Dir(config), "themes"),
155+ filepath.Join(home, ".local", "share", "ghostty", "themes"),
156+ "/usr/share/ghostty/themes",
157+ "/usr/local/share/ghostty/themes",
158+ }
159+}
160+
161+func loadGhostty(path string) (Theme, error) {
162+ var own Theme
163+ themeName := ""
164+ ls, err := lines(path)
165+ if err != nil {
166+ return own, err
167+ }
168+ own.Files = append(own.Files, path)
169+ for _, l := range ls {
170+ if strings.HasPrefix(l, "#") {
171+ continue
172+ }
173+ key, val, ok := strings.Cut(l, "=")
174+ if !ok {
175+ continue
176+ }
177+ key, val = strings.TrimSpace(key), strings.TrimSpace(val)
178+ switch key {
179+ case "theme":
180+ themeName = strings.Trim(val, `"'`)
181+ case "background":
182+ if c, ok := parseColor(val); ok {
183+ own.setBG(c)
184+ }
185+ case "foreground":
186+ if c, ok := parseColor(val); ok {
187+ own.setFG(c)
188+ }
189+ case "palette":
190+ idx, hex, ok := strings.Cut(val, "=")
191+ if !ok {
192+ continue
193+ }
194+ if n, err := strconv.Atoi(strings.TrimSpace(idx)); err == nil && n >= 0 && n < 16 {
195+ if c, ok := parseColor(hex); ok {
196+ own.setPal(n, c)
197+ }
198+ }
199+ }
200+ }
201+ // The named theme is applied first; the config's own colors win over it.
202+ var t Theme
203+ if themeName != "" {
204+ if file := findGhosttyTheme(path, themeName); file != "" {
205+ if th, err := loadGhostty(file); err == nil {
206+ t.overlay(th)
207+ }
208+ }
209+ }
210+ t.overlay(own)
211+ return t, nil
212+}
213+
214+// findGhosttyTheme resolves "dark:x,light:y" to x and looks the name up.
215+func findGhosttyTheme(config, name string) string {
216+ for _, part := range strings.Split(name, ",") {
217+ if k, v, ok := strings.Cut(part, ":"); ok {
218+ if strings.TrimSpace(k) == "dark" {
219+ name = strings.TrimSpace(v)
220+ }
221+ }
222+ }
223+ if k, v, ok := strings.Cut(name, ":"); ok && (k == "dark" || k == "light") {
224+ name = v
225+ }
226+ if filepath.IsAbs(name) {
227+ return name
228+ }
229+ for _, dir := range ghosttyDirs(config) {
230+ if p := filepath.Join(dir, name); fileExists(p) {
231+ return p
232+ }
233+ }
234+ return ""
235+}
236+
237+// ---- alacritty: toml, import = [files], [colors.*] -------------------------
238+
239+type alacrittyFile struct {
240+ Import []string `toml:"import"` // older configs
241+ General struct {
242+ Import []string `toml:"import"`
243+ } `toml:"general"`
244+ Colors struct {
245+ Primary struct {
246+ Background string `toml:"background"`
247+ Foreground string `toml:"foreground"`
248+ } `toml:"primary"`
249+ Normal map[string]string `toml:"normal"`
250+ Bright map[string]string `toml:"bright"`
251+ } `toml:"colors"`
252+}
253+
254+var alacrittyNames = [8]string{"black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"}
255+
256+func loadAlacritty(path string, depth int) (Theme, error) {
257+ var t Theme
258+ var f alacrittyFile
259+ if _, err := toml.DecodeFile(path, &f); err != nil {
260+ return t, err
261+ }
262+ t.Files = append(t.Files, path)
263+ // Imports come first, so the file's own colors win over them.
264+ if depth < maxDepth {
265+ for _, imp := range append(f.General.Import, f.Import...) {
266+ if inc, err := loadAlacritty(expand(imp, filepath.Dir(path)), depth+1); err == nil {
267+ t.overlay(inc)
268+ }
269+ }
270+ }
271+ if c, ok := parseColor(f.Colors.Primary.Background); ok {
272+ t.setBG(c)
273+ }
274+ if c, ok := parseColor(f.Colors.Primary.Foreground); ok {
275+ t.setFG(c)
276+ }
277+ for i, name := range alacrittyNames {
278+ if c, ok := parseColor(f.Colors.Normal[name]); ok {
279+ t.setPal(i, c)
280+ }
281+ if c, ok := parseColor(f.Colors.Bright[name]); ok {
282+ t.setPal(8+i, c)
283+ }
284+ }
285+ return t, nil
286+}
287+
288+func fileExists(p string) bool {
289+ st, err := os.Stat(p)
290+ return err == nil && !st.IsDir()
291+}
292+
293+var errNoTerminal = errors.New("theme: no terminal config found")
294+
295+func noTheme(tried []string) error {
296+ return fmt.Errorf("%w (looked for %s)", errNoTerminal, strings.Join(tried, ", "))
297+}
A internal/theme/theme.go +228 -0
@@ -0,0 +1,228 @@
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.
4+package theme
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "regexp"
11+ "strconv"
12+ "strings"
13+)
14+
15+// RGB is a color.
16+type 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).
20+type 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.
34+func 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.
48+func (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+// Defines reports how many of the 18 colors the source set.
64+func (t Theme) Defines() int {
65+ n := 0
66+ if t.hasBG {
67+ n++
68+ }
69+ if t.hasFG {
70+ n++
71+ }
72+ for _, h := range t.hasPal {
73+ if h {
74+ n++
75+ }
76+ }
77+ return n
78+}
79+
80+func (t *Theme) setBG(c RGB) { t.Background, t.hasBG = c, true }
81+func (t *Theme) setFG(c RGB) { t.Foreground, t.hasFG = c, true }
82+func (t *Theme) setPal(i int, c RGB) { t.Palette[i], t.hasPal[i] = c, true }
83+
84+// overlay applies every color src defines on top of t.
85+func (t *Theme) overlay(src Theme) {
86+ t.Files = append(t.Files, src.Files...)
87+ if src.hasBG {
88+ t.setBG(src.Background)
89+ }
90+ if src.hasFG {
91+ t.setFG(src.Foreground)
92+ }
93+ for i, h := range src.hasPal {
94+ if h {
95+ t.setPal(i, src.Palette[i])
96+ }
97+ }
98+}
99+
100+var hexRe = regexp.MustCompile(`^(?:#|0x|0X)?([0-9a-fA-F]{6})$`)
101+
102+// parseColor accepts #rrggbb, rrggbb and 0xrrggbb, with optional quotes.
103+func parseColor(s string) (RGB, bool) {
104+ s = strings.Trim(strings.TrimSpace(s), `"'`)
105+ m := hexRe.FindStringSubmatch(s)
106+ if m == nil {
107+ return RGB{}, false
108+ }
109+ v, _ := strconv.ParseUint(m[1], 16, 32)
110+ return RGB{uint8(v >> 16), uint8(v >> 8), uint8(v)}, true
111+}
112+
113+// expand resolves a leading ~ and, for a relative path, joins it to dir.
114+func expand(p, dir string) string {
115+ p = strings.Trim(strings.TrimSpace(p), `"'`)
116+ if p == "~" || strings.HasPrefix(p, "~/") {
117+ home, _ := os.UserHomeDir()
118+ p = filepath.Join(home, strings.TrimPrefix(p, "~"))
119+ }
120+ if !filepath.IsAbs(p) {
121+ p = filepath.Join(dir, p)
122+ }
123+ return p
124+}
125+
126+// maxDepth bounds include chains, so a loop cannot hang the bar.
127+const maxDepth = 8
128+
129+// Load reads the theme file at path. The format is recognized from the file:
130+// a .toml file is alacritty, a file with "palette = N=#rrggbb" or "theme =" lines is
131+// ghostty, one with a [colors] section is foot, and anything else is kitty.
132+func Load(path string) (Theme, error) {
133+ f, err := detectFormat(path)
134+ if err != nil {
135+ return Theme{}, err
136+ }
137+ t, err := f.load(path)
138+ if err != nil {
139+ return Theme{}, err
140+ }
141+ if t.Defines() == 0 {
142+ return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
143+ }
144+ t.Source = fmt.Sprintf("%s (%s)", path, f.name)
145+ return t, nil
146+}
147+
148+var (
149+ ghosttyHint = regexp.MustCompile(`(?m)^\s*(palette\s*=\s*\d+\s*=|theme\s*=)`)
150+ footHint = regexp.MustCompile(`(?m)^\s*\[colors`)
151+)
152+
153+func detectFormat(path string) (format, error) {
154+ data, err := os.ReadFile(path)
155+ if err != nil {
156+ return format{}, fmt.Errorf("theme: %w", err)
157+ }
158+ switch {
159+ case strings.HasSuffix(path, ".toml"):
160+ return formats["alacritty"], nil
161+ case ghosttyHint.Match(data):
162+ return formats["ghostty"], nil
163+ case footHint.Match(data):
164+ return formats["foot"], nil
165+ }
166+ return formats["kitty"], nil
167+}
168+
169+type format struct {
170+ name string
171+ load func(path string) (Theme, error)
172+}
173+
174+var formats = map[string]format{}
175+
176+func init() {
177+ formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
178+ formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
179+ formats["ghostty"] = format{"ghostty", loadGhostty}
180+ formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
181+}
182+
183+// Resolve turns the bar.theme setting into a complete theme. spec is "auto"
184+// (the terminal in use) or the path of a theme or config file; a relative
185+// path is taken from baseDir. Colors the source leaves out come from Default.
186+func Resolve(spec, baseDir string, env Env) (Theme, error) {
187+ def := Default()
188+ if spec == "auto" {
189+ t, err := Auto(env)
190+ if err != nil {
191+ return def, err
192+ }
193+ return t.Fill(def), nil
194+ }
195+ t, err := Load(expand(spec, baseDir))
196+ if err != nil {
197+ return def, err
198+ }
199+ return t.Fill(def), nil
200+}
201+
202+// Mix returns a blended with b: t=0 is a, t=1 is b.
203+func Mix(a, b RGB, t float64) RGB {
204+ var o RGB
205+ for i := range o {
206+ o[i] = uint8(float64(a[i]) + (float64(b[i])-float64(a[i]))*t + 0.5)
207+ }
208+ return o
209+}
210+
211+// Hex formats a color as #rrggbb.
212+func Hex(c RGB) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
213+
214+// EnvVars returns the colors as environment entries for scripts:
215+// NEFERBAR_BACKGROUND, NEFERBAR_FOREGROUND and NEFERBAR_COLOR0..15, each
216+// #rrggbb. bg and fg are the colors the bar really uses.
217+func (t Theme) EnvVars(bg, fg RGB) []string {
218+ out := []string{"NEFERBAR_BACKGROUND=" + Hex(bg), "NEFERBAR_FOREGROUND=" + Hex(fg)}
219+ for i, c := range t.Palette {
220+ out = append(out, fmt.Sprintf("NEFERBAR_COLOR%d=%s", i, Hex(c)))
221+ }
222+ return out
223+}
224+
225+// Equal reports whether two themes have the same colors.
226+func (t Theme) Equal(o Theme) bool {
227+ return t.Background == o.Background && t.Foreground == o.Foreground && t.Palette == o.Palette
228+}
@@ -0,0 +1,228 @@
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.
4+package theme
5+
6+import (
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "regexp"
11+ "strconv"
12+ "strings"
13+)
14+
15+// RGB is a color.
16+type 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).
20+type 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.
34+func 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.
48+func (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+// Defines reports how many of the 18 colors the source set.
64+func (t Theme) Defines() int {
65+ n := 0
66+ if t.hasBG {
67+ n++
68+ }
69+ if t.hasFG {
70+ n++
71+ }
72+ for _, h := range t.hasPal {
73+ if h {
74+ n++
75+ }
76+ }
77+ return n
78+}
79+
80+func (t *Theme) setBG(c RGB) { t.Background, t.hasBG = c, true }
81+func (t *Theme) setFG(c RGB) { t.Foreground, t.hasFG = c, true }
82+func (t *Theme) setPal(i int, c RGB) { t.Palette[i], t.hasPal[i] = c, true }
83+
84+// overlay applies every color src defines on top of t.
85+func (t *Theme) overlay(src Theme) {
86+ t.Files = append(t.Files, src.Files...)
87+ if src.hasBG {
88+ t.setBG(src.Background)
89+ }
90+ if src.hasFG {
91+ t.setFG(src.Foreground)
92+ }
93+ for i, h := range src.hasPal {
94+ if h {
95+ t.setPal(i, src.Palette[i])
96+ }
97+ }
98+}
99+
100+var hexRe = regexp.MustCompile(`^(?:#|0x|0X)?([0-9a-fA-F]{6})$`)
101+
102+// parseColor accepts #rrggbb, rrggbb and 0xrrggbb, with optional quotes.
103+func parseColor(s string) (RGB, bool) {
104+ s = strings.Trim(strings.TrimSpace(s), `"'`)
105+ m := hexRe.FindStringSubmatch(s)
106+ if m == nil {
107+ return RGB{}, false
108+ }
109+ v, _ := strconv.ParseUint(m[1], 16, 32)
110+ return RGB{uint8(v >> 16), uint8(v >> 8), uint8(v)}, true
111+}
112+
113+// expand resolves a leading ~ and, for a relative path, joins it to dir.
114+func expand(p, dir string) string {
115+ p = strings.Trim(strings.TrimSpace(p), `"'`)
116+ if p == "~" || strings.HasPrefix(p, "~/") {
117+ home, _ := os.UserHomeDir()
118+ p = filepath.Join(home, strings.TrimPrefix(p, "~"))
119+ }
120+ if !filepath.IsAbs(p) {
121+ p = filepath.Join(dir, p)
122+ }
123+ return p
124+}
125+
126+// maxDepth bounds include chains, so a loop cannot hang the bar.
127+const maxDepth = 8
128+
129+// Load reads the theme file at path. The format is recognized from the file:
130+// a .toml file is alacritty, a file with "palette = N=#rrggbb" or "theme =" lines is
131+// ghostty, one with a [colors] section is foot, and anything else is kitty.
132+func Load(path string) (Theme, error) {
133+ f, err := detectFormat(path)
134+ if err != nil {
135+ return Theme{}, err
136+ }
137+ t, err := f.load(path)
138+ if err != nil {
139+ return Theme{}, err
140+ }
141+ if t.Defines() == 0 {
142+ return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
143+ }
144+ t.Source = fmt.Sprintf("%s (%s)", path, f.name)
145+ return t, nil
146+}
147+
148+var (
149+ ghosttyHint = regexp.MustCompile(`(?m)^\s*(palette\s*=\s*\d+\s*=|theme\s*=)`)
150+ footHint = regexp.MustCompile(`(?m)^\s*\[colors`)
151+)
152+
153+func detectFormat(path string) (format, error) {
154+ data, err := os.ReadFile(path)
155+ if err != nil {
156+ return format{}, fmt.Errorf("theme: %w", err)
157+ }
158+ switch {
159+ case strings.HasSuffix(path, ".toml"):
160+ return formats["alacritty"], nil
161+ case ghosttyHint.Match(data):
162+ return formats["ghostty"], nil
163+ case footHint.Match(data):
164+ return formats["foot"], nil
165+ }
166+ return formats["kitty"], nil
167+}
168+
169+type format struct {
170+ name string
171+ load func(path string) (Theme, error)
172+}
173+
174+var formats = map[string]format{}
175+
176+func init() {
177+ formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
178+ formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
179+ formats["ghostty"] = format{"ghostty", loadGhostty}
180+ formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
181+}
182+
183+// Resolve turns the bar.theme setting into a complete theme. spec is "auto"
184+// (the terminal in use) or the path of a theme or config file; a relative
185+// path is taken from baseDir. Colors the source leaves out come from Default.
186+func Resolve(spec, baseDir string, env Env) (Theme, error) {
187+ def := Default()
188+ if spec == "auto" {
189+ t, err := Auto(env)
190+ if err != nil {
191+ return def, err
192+ }
193+ return t.Fill(def), nil
194+ }
195+ t, err := Load(expand(spec, baseDir))
196+ if err != nil {
197+ return def, err
198+ }
199+ return t.Fill(def), nil
200+}
201+
202+// Mix returns a blended with b: t=0 is a, t=1 is b.
203+func Mix(a, b RGB, t float64) RGB {
204+ var o RGB
205+ for i := range o {
206+ o[i] = uint8(float64(a[i]) + (float64(b[i])-float64(a[i]))*t + 0.5)
207+ }
208+ return o
209+}
210+
211+// Hex formats a color as #rrggbb.
212+func Hex(c RGB) string { return fmt.Sprintf("#%02x%02x%02x", c[0], c[1], c[2]) }
213+
214+// EnvVars returns the colors as environment entries for scripts:
215+// NEFERBAR_BACKGROUND, NEFERBAR_FOREGROUND and NEFERBAR_COLOR0..15, each
216+// #rrggbb. bg and fg are the colors the bar really uses.
217+func (t Theme) EnvVars(bg, fg RGB) []string {
218+ out := []string{"NEFERBAR_BACKGROUND=" + Hex(bg), "NEFERBAR_FOREGROUND=" + Hex(fg)}
219+ for i, c := range t.Palette {
220+ out = append(out, fmt.Sprintf("NEFERBAR_COLOR%d=%s", i, Hex(c)))
221+ }
222+ return out
223+}
224+
225+// Equal reports whether two themes have the same colors.
226+func (t Theme) Equal(o Theme) bool {
227+ return t.Background == o.Background && t.Foreground == o.Foreground && t.Palette == o.Palette
228+}
A internal/theme/theme_test.go +198 -0
@@ -0,0 +1,198 @@
1+package theme
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func put(t *testing.T, dir, rel, body string) string {
10+ t.Helper()
11+ p := filepath.Join(dir, rel)
12+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
13+ t.Fatal(err)
14+ }
15+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
16+ t.Fatal(err)
17+ }
18+ return p
19+}
20+
21+func want(t *testing.T, got Theme, bg, fg RGB, c1, c12 RGB) {
22+ t.Helper()
23+ if got.Background != bg || got.Foreground != fg || got.Palette[1] != c1 || got.Palette[12] != c12 {
24+ t.Errorf("got bg=%v fg=%v c1=%v c12=%v, want bg=%v fg=%v c1=%v c12=%v",
25+ got.Background, got.Foreground, got.Palette[1], got.Palette[12], bg, fg, c1, c12)
26+ }
27+}
28+
29+var (
30+ bg = RGB{0x07, 0x07, 0x22}
31+ fg = RGB{0xf3, 0xed, 0xf7}
32+ red = RGB{0xfd, 0x46, 0x63}
33+ blu = RGB{0xa9, 0xae, 0xfe}
34+)
35+
36+func TestKittyWithInclude(t *testing.T) {
37+ d := t.TempDir()
38+ put(t, d, "themes/x.conf", "background #070722\nforeground #f3edf7\ncolor1 #fd4663\ncolor12 #a9aefe\n")
39+ main := put(t, d, "kitty.conf", "font_size 12\ninclude themes/x.conf\n")
40+ got, err := Load(main)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ want(t, got, bg, fg, red, blu)
45+ if len(got.Files) != 2 {
46+ t.Errorf("Files = %v, want the config and the include", got.Files)
47+ }
48+}
49+
50+func TestKittyOwnColorsWinOverInclude(t *testing.T) {
51+ d := t.TempDir()
52+ put(t, d, "t.conf", "color1 #000001\n")
53+ main := put(t, d, "kitty.conf", "include t.conf\ncolor1 #fd4663\n")
54+ got, _ := Load(main)
55+ if got.Palette[1] != red {
56+ t.Errorf("c1 = %v, want the file's own %v", got.Palette[1], red)
57+ }
58+}
59+
60+func TestFootWithInclude(t *testing.T) {
61+ d := t.TempDir()
62+ put(t, d, "themes/n", "[colors-dark]\nbackground = 070722\nforeground = f3edf7\nregular1 = fd4663\nbright4 = a9aefe\n")
63+ main := put(t, d, "foot.ini", "include=~/nowhere\ninclude=themes/n\n[colors]\nforeground = 000000\n")
64+ got, err := Load(main)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ // [colors-dark] wins over the legacy [colors], and bright4 is color 12.
69+ want(t, got, bg, fg, red, blu)
70+}
71+
72+func TestGhosttyNamedThemeAndOverrides(t *testing.T) {
73+ d := t.TempDir()
74+ put(t, d, "themes/n", "palette = 1=#fd4663\npalette = 12=#a9aefe\nbackground = #070722\nforeground = #f3edf7\n")
75+ main := put(t, d, "config", "theme = n\nforeground = #ffffff\n")
76+ got, err := Load(main)
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ want(t, got, bg, RGB{255, 255, 255}, red, blu) // the config's own foreground wins
81+}
82+
83+func TestGhosttyDarkLightPair(t *testing.T) {
84+ d := t.TempDir()
85+ put(t, d, "themes/night", "palette = 1=#fd4663\n")
86+ put(t, d, "themes/day", "palette = 1=#000000\n")
87+ main := put(t, d, "config", "theme = dark:night,light:day\n")
88+ got, _ := Load(main)
89+ if got.Palette[1] != red {
90+ t.Errorf("dark theme not chosen: c1 = %v", got.Palette[1])
91+ }
92+}
93+
94+func TestAlacrittyImport(t *testing.T) {
95+ d := t.TempDir()
96+ put(t, d, "themes/n.toml", "[colors.primary]\nbackground = '#070722'\nforeground = '#f3edf7'\n[colors.normal]\nred = '#fd4663'\n[colors.bright]\nblue = '#a9aefe'\n")
97+ main := put(t, d, "alacritty.toml", "[general]\nimport = [\"themes/n.toml\"]\n")
98+ got, err := Load(main)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ want(t, got, bg, fg, red, blu)
103+}
104+
105+func TestAlacrittyOwnColorsWinOverImport(t *testing.T) {
106+ d := t.TempDir()
107+ put(t, d, "n.toml", "[colors.primary]\nbackground = '#070722'\n")
108+ main := put(t, d, "a.toml", "import = [\"n.toml\"]\n[colors.primary]\nbackground = '#000000'\n")
109+ got, _ := Load(main)
110+ if got.Background != (RGB{}) {
111+ t.Errorf("bg = %v, want the file's own black", got.Background)
112+ }
113+}
114+
115+func TestIncludeLoopTerminates(t *testing.T) {
116+ d := t.TempDir()
117+ put(t, d, "a.conf", "include b.conf\ncolor1 #fd4663\n")
118+ put(t, d, "b.conf", "include a.conf\n")
119+ got, err := Load(filepath.Join(d, "a.conf"))
120+ if err != nil || got.Palette[1] != red {
121+ t.Fatalf("loop: err=%v c1=%v", err, got.Palette[1])
122+ }
123+}
124+
125+func TestLoadRejectsAFileWithNoColors(t *testing.T) {
126+ p := put(t, t.TempDir(), "x.conf", "font_size 12\n")
127+ if _, err := Load(p); err == nil {
128+ t.Fatal("a file without colors must be an error, not a silent default")
129+ }
130+ if _, err := Load(filepath.Join(t.TempDir(), "missing")); err == nil {
131+ t.Fatal("a missing file must be an error")
132+ }
133+}
134+
135+func fakeEnv(dir string, vars map[string]string) Env {
136+ return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }}
137+}
138+
139+func TestAutoPrefersTheTerminalInUse(t *testing.T) {
140+ d := t.TempDir()
141+ put(t, d, "kitty/kitty.conf", "color1 #000001\n")
142+ put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n")
143+ got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": "/usr/bin/foot"}))
144+ if err != nil || got.Palette[1] != red {
145+ t.Fatalf("$TERMINAL=foot: err=%v c1=%v", err, got.Palette[1])
146+ }
147+ put(t, d, "xdg-terminals.list", "# comment\nkitty.desktop\n")
148+ got, _ = Auto(fakeEnv(d, nil))
149+ if got.Palette[1] != (RGB{0, 0, 1}) {
150+ t.Fatalf("xdg-terminals.list=kitty: c1=%v", got.Palette[1])
151+ }
152+}
153+
154+func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) {
155+ d := t.TempDir()
156+ put(t, d, "kitty/kitty.conf", "font_size 12\n") // exists but has no colors
157+ put(t, d, "alacritty/alacritty.toml", "[colors.normal]\nred = '#fd4663'\n")
158+ got, err := Auto(fakeEnv(d, nil))
159+ if err != nil || got.Palette[1] != red {
160+ t.Fatalf("err=%v c1=%v", err, got.Palette[1])
161+ }
162+}
163+
164+func TestAutoWithNoTerminalIsAnError(t *testing.T) {
165+ if _, err := Auto(fakeEnv(t.TempDir(), nil)); err == nil {
166+ t.Fatal("no terminal config must be an error the caller can report")
167+ }
168+}
169+
170+func TestResolveFillsGapsFromTheDefault(t *testing.T) {
171+ d := t.TempDir()
172+ p := put(t, d, "t.conf", "color1 #fd4663\n")
173+ got, err := Resolve(p, "", fakeEnv(d, nil))
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if got.Palette[1] != red || got.Palette[2] != Default().Palette[2] || got.Background != Default().Background {
178+ t.Errorf("gaps not filled: %+v", got.Palette[:3])
179+ }
180+ // A relative path is taken from the config directory.
181+ rel, err := Resolve("t.conf", d, fakeEnv(d, nil))
182+ if err != nil || rel.Palette[1] != red {
183+ t.Errorf("relative path: err=%v", err)
184+ }
185+}
186+
187+func TestParseColorForms(t *testing.T) {
188+ for _, s := range []string{"#fd4663", "fd4663", "0xfd4663", `"#fd4663"`, "'fd4663'"} {
189+ if c, ok := parseColor(s); !ok || c != red {
190+ t.Errorf("parseColor(%q) = %v, %v", s, c, ok)
191+ }
192+ }
193+ for _, s := range []string{"", "#fff", "red", "#gg0000", "#fd466300"} {
194+ if _, ok := parseColor(s); ok {
195+ t.Errorf("parseColor(%q) accepted", s)
196+ }
197+ }
198+}
@@ -0,0 +1,198 @@
1+package theme
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func put(t *testing.T, dir, rel, body string) string {
10+ t.Helper()
11+ p := filepath.Join(dir, rel)
12+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
13+ t.Fatal(err)
14+ }
15+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
16+ t.Fatal(err)
17+ }
18+ return p
19+}
20+
21+func want(t *testing.T, got Theme, bg, fg RGB, c1, c12 RGB) {
22+ t.Helper()
23+ if got.Background != bg || got.Foreground != fg || got.Palette[1] != c1 || got.Palette[12] != c12 {
24+ t.Errorf("got bg=%v fg=%v c1=%v c12=%v, want bg=%v fg=%v c1=%v c12=%v",
25+ got.Background, got.Foreground, got.Palette[1], got.Palette[12], bg, fg, c1, c12)
26+ }
27+}
28+
29+var (
30+ bg = RGB{0x07, 0x07, 0x22}
31+ fg = RGB{0xf3, 0xed, 0xf7}
32+ red = RGB{0xfd, 0x46, 0x63}
33+ blu = RGB{0xa9, 0xae, 0xfe}
34+)
35+
36+func TestKittyWithInclude(t *testing.T) {
37+ d := t.TempDir()
38+ put(t, d, "themes/x.conf", "background #070722\nforeground #f3edf7\ncolor1 #fd4663\ncolor12 #a9aefe\n")
39+ main := put(t, d, "kitty.conf", "font_size 12\ninclude themes/x.conf\n")
40+ got, err := Load(main)
41+ if err != nil {
42+ t.Fatal(err)
43+ }
44+ want(t, got, bg, fg, red, blu)
45+ if len(got.Files) != 2 {
46+ t.Errorf("Files = %v, want the config and the include", got.Files)
47+ }
48+}
49+
50+func TestKittyOwnColorsWinOverInclude(t *testing.T) {
51+ d := t.TempDir()
52+ put(t, d, "t.conf", "color1 #000001\n")
53+ main := put(t, d, "kitty.conf", "include t.conf\ncolor1 #fd4663\n")
54+ got, _ := Load(main)
55+ if got.Palette[1] != red {
56+ t.Errorf("c1 = %v, want the file's own %v", got.Palette[1], red)
57+ }
58+}
59+
60+func TestFootWithInclude(t *testing.T) {
61+ d := t.TempDir()
62+ put(t, d, "themes/n", "[colors-dark]\nbackground = 070722\nforeground = f3edf7\nregular1 = fd4663\nbright4 = a9aefe\n")
63+ main := put(t, d, "foot.ini", "include=~/nowhere\ninclude=themes/n\n[colors]\nforeground = 000000\n")
64+ got, err := Load(main)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ // [colors-dark] wins over the legacy [colors], and bright4 is color 12.
69+ want(t, got, bg, fg, red, blu)
70+}
71+
72+func TestGhosttyNamedThemeAndOverrides(t *testing.T) {
73+ d := t.TempDir()
74+ put(t, d, "themes/n", "palette = 1=#fd4663\npalette = 12=#a9aefe\nbackground = #070722\nforeground = #f3edf7\n")
75+ main := put(t, d, "config", "theme = n\nforeground = #ffffff\n")
76+ got, err := Load(main)
77+ if err != nil {
78+ t.Fatal(err)
79+ }
80+ want(t, got, bg, RGB{255, 255, 255}, red, blu) // the config's own foreground wins
81+}
82+
83+func TestGhosttyDarkLightPair(t *testing.T) {
84+ d := t.TempDir()
85+ put(t, d, "themes/night", "palette = 1=#fd4663\n")
86+ put(t, d, "themes/day", "palette = 1=#000000\n")
87+ main := put(t, d, "config", "theme = dark:night,light:day\n")
88+ got, _ := Load(main)
89+ if got.Palette[1] != red {
90+ t.Errorf("dark theme not chosen: c1 = %v", got.Palette[1])
91+ }
92+}
93+
94+func TestAlacrittyImport(t *testing.T) {
95+ d := t.TempDir()
96+ put(t, d, "themes/n.toml", "[colors.primary]\nbackground = '#070722'\nforeground = '#f3edf7'\n[colors.normal]\nred = '#fd4663'\n[colors.bright]\nblue = '#a9aefe'\n")
97+ main := put(t, d, "alacritty.toml", "[general]\nimport = [\"themes/n.toml\"]\n")
98+ got, err := Load(main)
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ want(t, got, bg, fg, red, blu)
103+}
104+
105+func TestAlacrittyOwnColorsWinOverImport(t *testing.T) {
106+ d := t.TempDir()
107+ put(t, d, "n.toml", "[colors.primary]\nbackground = '#070722'\n")
108+ main := put(t, d, "a.toml", "import = [\"n.toml\"]\n[colors.primary]\nbackground = '#000000'\n")
109+ got, _ := Load(main)
110+ if got.Background != (RGB{}) {
111+ t.Errorf("bg = %v, want the file's own black", got.Background)
112+ }
113+}
114+
115+func TestIncludeLoopTerminates(t *testing.T) {
116+ d := t.TempDir()
117+ put(t, d, "a.conf", "include b.conf\ncolor1 #fd4663\n")
118+ put(t, d, "b.conf", "include a.conf\n")
119+ got, err := Load(filepath.Join(d, "a.conf"))
120+ if err != nil || got.Palette[1] != red {
121+ t.Fatalf("loop: err=%v c1=%v", err, got.Palette[1])
122+ }
123+}
124+
125+func TestLoadRejectsAFileWithNoColors(t *testing.T) {
126+ p := put(t, t.TempDir(), "x.conf", "font_size 12\n")
127+ if _, err := Load(p); err == nil {
128+ t.Fatal("a file without colors must be an error, not a silent default")
129+ }
130+ if _, err := Load(filepath.Join(t.TempDir(), "missing")); err == nil {
131+ t.Fatal("a missing file must be an error")
132+ }
133+}
134+
135+func fakeEnv(dir string, vars map[string]string) Env {
136+ return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }}
137+}
138+
139+func TestAutoPrefersTheTerminalInUse(t *testing.T) {
140+ d := t.TempDir()
141+ put(t, d, "kitty/kitty.conf", "color1 #000001\n")
142+ put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n")
143+ got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": "/usr/bin/foot"}))
144+ if err != nil || got.Palette[1] != red {
145+ t.Fatalf("$TERMINAL=foot: err=%v c1=%v", err, got.Palette[1])
146+ }
147+ put(t, d, "xdg-terminals.list", "# comment\nkitty.desktop\n")
148+ got, _ = Auto(fakeEnv(d, nil))
149+ if got.Palette[1] != (RGB{0, 0, 1}) {
150+ t.Fatalf("xdg-terminals.list=kitty: c1=%v", got.Palette[1])
151+ }
152+}
153+
154+func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) {
155+ d := t.TempDir()
156+ put(t, d, "kitty/kitty.conf", "font_size 12\n") // exists but has no colors
157+ put(t, d, "alacritty/alacritty.toml", "[colors.normal]\nred = '#fd4663'\n")
158+ got, err := Auto(fakeEnv(d, nil))
159+ if err != nil || got.Palette[1] != red {
160+ t.Fatalf("err=%v c1=%v", err, got.Palette[1])
161+ }
162+}
163+
164+func TestAutoWithNoTerminalIsAnError(t *testing.T) {
165+ if _, err := Auto(fakeEnv(t.TempDir(), nil)); err == nil {
166+ t.Fatal("no terminal config must be an error the caller can report")
167+ }
168+}
169+
170+func TestResolveFillsGapsFromTheDefault(t *testing.T) {
171+ d := t.TempDir()
172+ p := put(t, d, "t.conf", "color1 #fd4663\n")
173+ got, err := Resolve(p, "", fakeEnv(d, nil))
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+ if got.Palette[1] != red || got.Palette[2] != Default().Palette[2] || got.Background != Default().Background {
178+ t.Errorf("gaps not filled: %+v", got.Palette[:3])
179+ }
180+ // A relative path is taken from the config directory.
181+ rel, err := Resolve("t.conf", d, fakeEnv(d, nil))
182+ if err != nil || rel.Palette[1] != red {
183+ t.Errorf("relative path: err=%v", err)
184+ }
185+}
186+
187+func TestParseColorForms(t *testing.T) {
188+ for _, s := range []string{"#fd4663", "fd4663", "0xfd4663", `"#fd4663"`, "'fd4663'"} {
189+ if c, ok := parseColor(s); !ok || c != red {
190+ t.Errorf("parseColor(%q) = %v, %v", s, c, ok)
191+ }
192+ }
193+ for _, s := range []string{"", "#fff", "red", "#gg0000", "#fd466300"} {
194+ if _, ok := parseColor(s); ok {
195+ t.Errorf("parseColor(%q) accepted", s)
196+ }
197+ }
198+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 5

Tags 4

↑↓ moveenter openesc close