feat(theme): make auto read $TERMINAL only, and stop ghostty themes from recursing forever
- commit
- 44d4d499006156d2f303059c67aeec1940c4c2cb
- author
- brice · · 2026-10-05 16:00 UTC
- parent
- eff799f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +122 -94
M internal/theme/auto.go +33 -57
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "strings" | |
| @@ -10,16 +11,16 @@ import ( | |||
| 10 | 11 | type terminal struct { | |
| 11 | 12 | name string | |
| 12 | 13 | format string | |
| 13 | - | rel string // path under the config directory | |
| 14 | - | match []string // substrings of $TERMINAL or a desktop-file id | |
| 14 | + | rel string // path under the config directory | |
| 15 | 15 | } | |
| 16 | 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"}}, | |
| 17 | + | // terminals are the terminals auto knows how to read, by the name $TERMINAL | |
| 18 | + | // gives them. | |
| 19 | + | var terminals = map[string]terminal{ | |
| 20 | + | "kitty": {"kitty", "kitty", "kitty/kitty.conf"}, | |
| 21 | + | "ghostty": {"ghostty", "ghostty", "ghostty/config"}, | |
| 22 | + | "foot": {"foot", "foot", "foot/foot.ini"}, | |
| 23 | + | "alacritty": {"alacritty", "alacritty", "alacritty/alacritty.toml"}, | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // Env is how Auto reads its surroundings, so tests can fake them. | |
| @@ -38,58 +39,33 @@ func SystemEnv() Env { | |||
| 38 | 39 | return Env{Getenv: os.Getenv, ConfigDir: dir} | |
| 39 | 40 | } | |
| 40 | 41 | ||
| 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" | |
| 42 | + | // Auto reads the theme of the terminal named by $TERMINAL: a bare name such as | |
| 43 | + | // "kitty" or a path such as "/usr/bin/foot". It is deliberately the only rule, | |
| 44 | + | // so there is one place to look when the colors are not the ones you expect. | |
| 45 | + | func Auto(e Env) (Theme, error) { | |
| 46 | + | value := strings.TrimSpace(e.Getenv("TERMINAL")) | |
| 47 | + | if value == "" { | |
| 48 | + | return Theme{}, fmt.Errorf(`theme = "auto" needs $TERMINAL (for example TERMINAL=kitty). ` + | |
| 49 | + | `Put it in ~/.config/environment.d/10-terminal.conf and start a new session`) | |
| 47 | 50 | } | |
| 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 | - | } | |
| 51 | + | // Only the program name counts: "/usr/bin/foot --server" is foot. | |
| 52 | + | name := filepath.Base(strings.Fields(value)[0]) | |
| 53 | + | term, ok := terminals[name] | |
| 54 | + | if !ok { | |
| 55 | + | return Theme{}, fmt.Errorf("$TERMINAL is %q, but neferbar reads the themes of kitty, ghostty, foot and alacritty only; "+ | |
| 56 | + | `set bar.theme to the path of a theme file instead`, name) | |
| 55 | 57 | } | |
| 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 | - | } | |
| 58 | + | path := filepath.Join(e.ConfigDir, term.rel) | |
| 59 | + | if !fileExists(path) { | |
| 60 | + | return Theme{}, fmt.Errorf("$TERMINAL is %s, but %s does not exist", name, path) | |
| 76 | 61 | } | |
| 77 | - | for _, t := range terminals { | |
| 78 | - | order = append(order, t) | |
| 62 | + | th, err := formats[term.format].load(path) | |
| 63 | + | if err != nil { | |
| 64 | + | return Theme{}, fmt.Errorf("cannot read the %s config %s: %w", name, path, err) | |
| 79 | 65 | } | |
| 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 | |
| 66 | + | if th.Defines() == 0 { | |
| 67 | + | return Theme{}, fmt.Errorf("the %s config %s sets no colors", name, path) | |
| 93 | 68 | } | |
| 94 | - | return Theme{}, noTheme(tried) | |
| 69 | + | th.Source = fmt.Sprintf("%s (%s), from $TERMINAL", name, path) | |
| 70 | + | return th, nil | |
| 95 | 71 | } | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "strings" | 7 | "strings" | ||
| @@ -10,16 +11,16 @@ import ( | |||||
| 10 | type terminal struct { | 11 | type terminal struct { | ||
| 11 | name string | 12 | name string | ||
| 12 | format string | 13 | format string | ||
| 13 | - | rel string // path under the config directory | 14 | + | rel string // path under the config directory |
| 14 | - | match []string // substrings of $TERMINAL or a desktop-file id | |||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | // terminals is the order used when nothing says which one is in use. | 17 | + | // terminals are the terminals auto knows how to read, by the name $TERMINAL |
| 18 | - | var terminals = []terminal{ | 18 | + | // gives them. |
| 19 | - | {"kitty", "kitty", "kitty/kitty.conf", []string{"kitty"}}, | 19 | + | var terminals = map[string]terminal{ |
| 20 | - | {"ghostty", "ghostty", "ghostty/config", []string{"ghostty"}}, | 20 | + | "kitty": {"kitty", "kitty", "kitty/kitty.conf"}, |
| 21 | - | {"foot", "foot", "foot/foot.ini", []string{"foot"}}, | 21 | + | "ghostty": {"ghostty", "ghostty", "ghostty/config"}, |
| 22 | - | {"alacritty", "alacritty", "alacritty/alacritty.toml", []string{"alacritty"}}, | 22 | + | "foot": {"foot", "foot", "foot/foot.ini"}, |
| 23 | + | "alacritty": {"alacritty", "alacritty", "alacritty/alacritty.toml"}, | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // Env is how Auto reads its surroundings, so tests can fake them. | 26 | // Env is how Auto reads its surroundings, so tests can fake them. | ||
| @@ -38,58 +39,33 @@ func SystemEnv() Env { | |||||
| 38 | return Env{Getenv: os.Getenv, ConfigDir: dir} | 39 | return Env{Getenv: os.Getenv, ConfigDir: dir} | ||
| 39 | } | 40 | } | ||
| 40 | 41 | ||||
| 41 | - | // preferred names the terminal the user says they use, or "". It looks at | 42 | + | // Auto reads the theme of the terminal named by $TERMINAL: a bare name such as |
| 42 | - | // $TERMINAL, then at the first entry of xdg-terminals.list (the file read by | 43 | + | // "kitty" or a path such as "/usr/bin/foot". It is deliberately the only rule, |
| 43 | - | // xdg-terminal-exec). | 44 | + | // so there is one place to look when the colors are not the ones you expect. |
| 44 | - | func (e Env) preferred() (string, string) { | 45 | + | func Auto(e Env) (Theme, error) { |
| 45 | - | if v := e.Getenv("TERMINAL"); v != "" { | 46 | + | value := strings.TrimSpace(e.Getenv("TERMINAL")) |
| 46 | - | return filepath.Base(strings.Fields(v)[0]), "$TERMINAL" | 47 | + | if value == "" { |
| 48 | + | return Theme{}, fmt.Errorf(`theme = "auto" needs $TERMINAL (for example TERMINAL=kitty). ` + | |||
| 49 | + | `Put it in ~/.config/environment.d/10-terminal.conf and start a new session`) | |||
| 47 | } | 50 | } | ||
| 48 | - | data, err := os.ReadFile(filepath.Join(e.ConfigDir, "xdg-terminals.list")) | 51 | + | // Only the program name counts: "/usr/bin/foot --server" is foot. |
| 49 | - | if err == nil { | 52 | + | name := filepath.Base(strings.Fields(value)[0]) |
| 50 | - | for _, l := range strings.Split(string(data), "\n") { | 53 | + | term, ok := terminals[name] |
| 51 | - | if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") { | 54 | + | if !ok { |
| 52 | - | return l, "xdg-terminals.list" | 55 | + | return Theme{}, fmt.Errorf("$TERMINAL is %q, but neferbar reads the themes of kitty, ghostty, foot and alacritty only; "+ |
| 53 | - | } | 56 | + | `set bar.theme to the path of a theme file instead`, name) |
| 54 | - | } | |||
| 55 | } | 57 | } | ||
| 56 | - | return "", "" | 58 | + | path := filepath.Join(e.ConfigDir, term.rel) |
| 57 | - | } | 59 | + | if !fileExists(path) { |
| 58 | - | 60 | + | return Theme{}, fmt.Errorf("$TERMINAL is %s, but %s does not exist", name, path) | |
| 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 | } | 61 | } | ||
| 77 | - | for _, t := range terminals { | 62 | + | th, err := formats[term.format].load(path) |
| 78 | - | order = append(order, t) | 63 | + | if err != nil { |
| 64 | + | return Theme{}, fmt.Errorf("cannot read the %s config %s: %w", name, path, err) | |||
| 79 | } | 65 | } | ||
| 80 | - | var tried []string | 66 | + | if th.Defines() == 0 { |
| 81 | - | for _, t := range order { | 67 | + | return Theme{}, fmt.Errorf("the %s config %s sets no colors", name, path) |
| 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 | } | 68 | } | ||
| 94 | - | return Theme{}, noTheme(tried) | 69 | + | th.Source = fmt.Sprintf("%s (%s), from $TERMINAL", name, path) |
| 70 | + | return th, nil | |||
| 95 | } | 71 | } | ||
M internal/theme/formats.go +13 -13
| @@ -2,8 +2,6 @@ package theme | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bufio" | |
| 5 | - | "errors" | |
| 6 | - | "fmt" | |
| 7 | 5 | "os" | |
| 8 | 6 | "path/filepath" | |
| 9 | 7 | "strconv" | |
| @@ -29,6 +27,15 @@ func lines(path string) ([]string, error) { | |||
| 29 | 27 | return out, sc.Err() | |
| 30 | 28 | } | |
| 31 | 29 | ||
| 30 | + | // splitFirstWord splits "key value" at the first run of spaces or tabs. | |
| 31 | + | func 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 | + | ||
| 32 | 39 | // ---- kitty: "key value" lines, "include file" ------------------------------ | |
| 33 | 40 | ||
| 34 | 41 | func loadKitty(path string, depth int) (Theme, error) { | |
| @@ -42,8 +49,7 @@ func loadKitty(path string, depth int) (Theme, error) { | |||
| 42 | 49 | if strings.HasPrefix(l, "#") { | |
| 43 | 50 | continue | |
| 44 | 51 | } | |
| 45 | - | key, val, _ := strings.Cut(l, " ") | |
| 46 | - | val = strings.TrimSpace(val) | |
| 52 | + | key, val := splitFirstWord(l) | |
| 47 | 53 | switch { | |
| 48 | 54 | case key == "include" && depth < maxDepth: | |
| 49 | 55 | inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1) | |
| @@ -158,7 +164,7 @@ func ghosttyDirs(config string) []string { | |||
| 158 | 164 | } | |
| 159 | 165 | } | |
| 160 | 166 | ||
| 161 | - | func loadGhostty(path string) (Theme, error) { | |
| 167 | + | func loadGhostty(path string, depth int) (Theme, error) { | |
| 162 | 168 | var own Theme | |
| 163 | 169 | themeName := "" | |
| 164 | 170 | ls, err := lines(path) | |
| @@ -201,8 +207,8 @@ func loadGhostty(path string) (Theme, error) { | |||
| 201 | 207 | // The named theme is applied first; the config's own colors win over it. | |
| 202 | 208 | var t Theme | |
| 203 | 209 | if themeName != "" { | |
| 204 | - | if file := findGhosttyTheme(path, themeName); file != "" { | |
| 205 | - | if th, err := loadGhostty(file); err == nil { | |
| 210 | + | if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth { | |
| 211 | + | if th, err := loadGhostty(file, depth+1); err == nil { | |
| 206 | 212 | t.overlay(th) | |
| 207 | 213 | } | |
| 208 | 214 | } | |
| @@ -289,9 +295,3 @@ func fileExists(p string) bool { | |||
| 289 | 295 | st, err := os.Stat(p) | |
| 290 | 296 | return err == nil && !st.IsDir() | |
| 291 | 297 | } | |
| 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 | - | } | |
| @@ -2,8 +2,6 @@ package theme | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | - | "errors" | |||
| 6 | - | "fmt" | |||
| 7 | "os" | 5 | "os" | ||
| 8 | "path/filepath" | 6 | "path/filepath" | ||
| 9 | "strconv" | 7 | "strconv" | ||
| @@ -29,6 +27,15 @@ func lines(path string) ([]string, error) { | |||||
| 29 | return out, sc.Err() | 27 | return out, sc.Err() | ||
| 30 | } | 28 | } | ||
| 31 | 29 | ||||
| 30 | + | // splitFirstWord splits "key value" at the first run of spaces or tabs. | |||
| 31 | + | func 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 | + | ||||
| 32 | // ---- kitty: "key value" lines, "include file" ------------------------------ | 39 | // ---- kitty: "key value" lines, "include file" ------------------------------ | ||
| 33 | 40 | ||||
| 34 | func loadKitty(path string, depth int) (Theme, error) { | 41 | func loadKitty(path string, depth int) (Theme, error) { | ||
| @@ -42,8 +49,7 @@ func loadKitty(path string, depth int) (Theme, error) { | |||||
| 42 | if strings.HasPrefix(l, "#") { | 49 | if strings.HasPrefix(l, "#") { | ||
| 43 | continue | 50 | continue | ||
| 44 | } | 51 | } | ||
| 45 | - | key, val, _ := strings.Cut(l, " ") | 52 | + | key, val := splitFirstWord(l) |
| 46 | - | val = strings.TrimSpace(val) | |||
| 47 | switch { | 53 | switch { | ||
| 48 | case key == "include" && depth < maxDepth: | 54 | case key == "include" && depth < maxDepth: | ||
| 49 | inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1) | 55 | inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1) | ||
| @@ -158,7 +164,7 @@ func ghosttyDirs(config string) []string { | |||||
| 158 | } | 164 | } | ||
| 159 | } | 165 | } | ||
| 160 | 166 | ||||
| 161 | - | func loadGhostty(path string) (Theme, error) { | 167 | + | func loadGhostty(path string, depth int) (Theme, error) { |
| 162 | var own Theme | 168 | var own Theme | ||
| 163 | themeName := "" | 169 | themeName := "" | ||
| 164 | ls, err := lines(path) | 170 | ls, err := lines(path) | ||
| @@ -201,8 +207,8 @@ func loadGhostty(path string) (Theme, error) { | |||||
| 201 | // The named theme is applied first; the config's own colors win over it. | 207 | // The named theme is applied first; the config's own colors win over it. | ||
| 202 | var t Theme | 208 | var t Theme | ||
| 203 | if themeName != "" { | 209 | if themeName != "" { | ||
| 204 | - | if file := findGhosttyTheme(path, themeName); file != "" { | 210 | + | if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth { |
| 205 | - | if th, err := loadGhostty(file); err == nil { | 211 | + | if th, err := loadGhostty(file, depth+1); err == nil { |
| 206 | t.overlay(th) | 212 | t.overlay(th) | ||
| 207 | } | 213 | } | ||
| 208 | } | 214 | } | ||
| @@ -289,9 +295,3 @@ func fileExists(p string) bool { | |||||
| 289 | st, err := os.Stat(p) | 295 | st, err := os.Stat(p) | ||
| 290 | return err == nil && !st.IsDir() | 296 | return err == nil && !st.IsDir() | ||
| 291 | } | 297 | } | ||
| 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 | - | } | |||
M internal/theme/theme.go +23 -8
| @@ -60,6 +60,19 @@ func (t Theme) Fill(def Theme) Theme { | |||
| 60 | 60 | return t | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | // dedupFiles removes repeated entries, keeping the first of each. | |
| 64 | + | func 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 | + | ||
| 63 | 76 | // Defines reports how many of the 18 colors the source set. | |
| 64 | 77 | func (t Theme) Defines() int { | |
| 65 | 78 | n := 0 | |
| @@ -142,6 +155,7 @@ func Load(path string) (Theme, error) { | |||
| 142 | 155 | return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name) | |
| 143 | 156 | } | |
| 144 | 157 | t.Source = fmt.Sprintf("%s (%s)", path, f.name) | |
| 158 | + | t.Files = dedupFiles(t.Files) | |
| 145 | 159 | return t, nil | |
| 146 | 160 | } | |
| 147 | 161 | ||
| @@ -176,7 +190,7 @@ var formats = map[string]format{} | |||
| 176 | 190 | func init() { | |
| 177 | 191 | formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }} | |
| 178 | 192 | formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }} | |
| 179 | - | formats["ghostty"] = format{"ghostty", loadGhostty} | |
| 193 | + | formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }} | |
| 180 | 194 | formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }} | |
| 181 | 195 | } | |
| 182 | 196 | ||
| @@ -185,18 +199,19 @@ func init() { | |||
| 185 | 199 | // path is taken from baseDir. Colors the source leaves out come from Default. | |
| 186 | 200 | func Resolve(spec, baseDir string, env Env) (Theme, error) { | |
| 187 | 201 | def := Default() | |
| 202 | + | var t Theme | |
| 203 | + | var err error | |
| 188 | 204 | if spec == "auto" { | |
| 189 | - | t, err := Auto(env) | |
| 190 | - | if err != nil { | |
| 191 | - | return def, err | |
| 192 | - | } | |
| 193 | - | return t.Fill(def), nil | |
| 205 | + | t, err = Auto(env) | |
| 206 | + | } else { | |
| 207 | + | t, err = Load(expand(spec, baseDir)) | |
| 194 | 208 | } | |
| 195 | - | t, err := Load(expand(spec, baseDir)) | |
| 196 | 209 | if err != nil { | |
| 197 | 210 | return def, err | |
| 198 | 211 | } | |
| 199 | - | return t.Fill(def), nil | |
| 212 | + | t = t.Fill(def) | |
| 213 | + | t.Files = dedupFiles(t.Files) | |
| 214 | + | return t, nil | |
| 200 | 215 | } | |
| 201 | 216 | ||
| 202 | 217 | // Mix returns a blended with b: t=0 is a, t=1 is b. | |
| @@ -60,6 +60,19 @@ func (t Theme) Fill(def Theme) Theme { | |||||
| 60 | return t | 60 | return t | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | + | // dedupFiles removes repeated entries, keeping the first of each. | |||
| 64 | + | func 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 | + | ||||
| 63 | // Defines reports how many of the 18 colors the source set. | 76 | // Defines reports how many of the 18 colors the source set. | ||
| 64 | func (t Theme) Defines() int { | 77 | func (t Theme) Defines() int { | ||
| 65 | n := 0 | 78 | n := 0 | ||
| @@ -142,6 +155,7 @@ func Load(path string) (Theme, error) { | |||||
| 142 | return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name) | 155 | return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name) | ||
| 143 | } | 156 | } | ||
| 144 | t.Source = fmt.Sprintf("%s (%s)", path, f.name) | 157 | t.Source = fmt.Sprintf("%s (%s)", path, f.name) | ||
| 158 | + | t.Files = dedupFiles(t.Files) | |||
| 145 | return t, nil | 159 | return t, nil | ||
| 146 | } | 160 | } | ||
| 147 | 161 | ||||
| @@ -176,7 +190,7 @@ var formats = map[string]format{} | |||||
| 176 | func init() { | 190 | func init() { | ||
| 177 | formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }} | 191 | 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) }} | 192 | formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }} | ||
| 179 | - | formats["ghostty"] = format{"ghostty", loadGhostty} | 193 | + | formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }} |
| 180 | formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }} | 194 | formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }} | ||
| 181 | } | 195 | } | ||
| 182 | 196 | ||||
| @@ -185,18 +199,19 @@ func init() { | |||||
| 185 | // path is taken from baseDir. Colors the source leaves out come from Default. | 199 | // path is taken from baseDir. Colors the source leaves out come from Default. | ||
| 186 | func Resolve(spec, baseDir string, env Env) (Theme, error) { | 200 | func Resolve(spec, baseDir string, env Env) (Theme, error) { | ||
| 187 | def := Default() | 201 | def := Default() | ||
| 202 | + | var t Theme | |||
| 203 | + | var err error | |||
| 188 | if spec == "auto" { | 204 | if spec == "auto" { | ||
| 189 | - | t, err := Auto(env) | 205 | + | t, err = Auto(env) |
| 190 | - | if err != nil { | 206 | + | } else { |
| 191 | - | return def, err | 207 | + | t, err = Load(expand(spec, baseDir)) |
| 192 | - | } | |||
| 193 | - | return t.Fill(def), nil | |||
| 194 | } | 208 | } | ||
| 195 | - | t, err := Load(expand(spec, baseDir)) | |||
| 196 | if err != nil { | 209 | if err != nil { | ||
| 197 | return def, err | 210 | return def, err | ||
| 198 | } | 211 | } | ||
| 199 | - | return t.Fill(def), nil | 212 | + | t = t.Fill(def) |
| 213 | + | t.Files = dedupFiles(t.Files) | |||
| 214 | + | return t, nil | |||
| 200 | } | 215 | } | ||
| 201 | 216 | ||||
| 202 | // Mix returns a blended with b: t=0 is a, t=1 is b. | 217 | // Mix returns a blended with b: t=0 is a, t=1 is b. | ||
M internal/theme/theme_test.go +53 -16
| @@ -3,6 +3,7 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -136,34 +137,70 @@ func fakeEnv(dir string, vars map[string]string) Env { | |||
| 136 | 137 | return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }} | |
| 137 | 138 | } | |
| 138 | 139 | ||
| 139 | - | func TestAutoPrefersTheTerminalInUse(t *testing.T) { | |
| 140 | + | func TestAutoReadsOnlyTheTerminalVariable(t *testing.T) { | |
| 140 | 141 | d := t.TempDir() | |
| 141 | 142 | put(t, d, "kitty/kitty.conf", "color1 #000001\n") | |
| 142 | 143 | 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]) | |
| 144 | + | for _, value := range []string{"foot", "/usr/bin/foot", "/usr/bin/foot --server", " foot "} { | |
| 145 | + | got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": value})) | |
| 146 | + | if err != nil || got.Palette[1] != red { | |
| 147 | + | t.Errorf("TERMINAL=%q: err=%v c1=%v", value, err, got.Palette[1]) | |
| 148 | + | } | |
| 146 | 149 | } | |
| 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]) | |
| 150 | + | // A config for another terminal must not be picked up by accident. | |
| 151 | + | if got, _ := Auto(fakeEnv(d, map[string]string{"TERMINAL": "kitty"})); got.Palette[1] != (RGB{0, 0, 1}) { | |
| 152 | + | t.Errorf("TERMINAL=kitty read the wrong file: c1=%v", got.Palette[1]) | |
| 151 | 153 | } | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 154 | - | func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) { | |
| 156 | + | func TestAutoErrorsAreOneClearSentence(t *testing.T) { | |
| 155 | 157 | 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)) | |
| 158 | + | put(t, d, "kitty/kitty.conf", "font_size 12\n") // no colors | |
| 159 | + | cases := map[string]struct{ env, contains string }{ | |
| 160 | + | "unset": {"", "needs $TERMINAL"}, | |
| 161 | + | "blank": {" ", "needs $TERMINAL"}, | |
| 162 | + | "unknown": {"xterm", "kitty, ghostty, foot and alacritty only"}, | |
| 163 | + | "no config": {"foot", "does not exist"}, | |
| 164 | + | "no colors": {"kitty", "sets no colors"}, | |
| 165 | + | } | |
| 166 | + | for name, c := range cases { | |
| 167 | + | _, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": c.env})) | |
| 168 | + | if err == nil || !strings.Contains(err.Error(), c.contains) { | |
| 169 | + | t.Errorf("%s: err = %v, want it to mention %q", name, err, c.contains) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | // The unset message tells the user where to put the variable. | |
| 173 | + | _, err := Auto(fakeEnv(d, nil)) | |
| 174 | + | if err == nil || !strings.Contains(err.Error(), "environment.d") { | |
| 175 | + | t.Errorf("unset message must name environment.d: %v", err) | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func TestGhosttySelfReferenceTerminates(t *testing.T) { | |
| 180 | + | d := t.TempDir() | |
| 181 | + | self := filepath.Join(d, "config") | |
| 182 | + | put(t, d, "config", "theme = "+self+"\npalette = 1=#fd4663\n") | |
| 183 | + | got, err := Load(self) | |
| 159 | 184 | if err != nil || got.Palette[1] != red { | |
| 160 | - | t.Fatalf("err=%v c1=%v", err, got.Palette[1]) | |
| 185 | + | t.Fatalf("self-referencing ghostty theme: err=%v c1=%v", err, got.Palette[1]) | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func TestKittyKeysSeparatedByATab(t *testing.T) { | |
| 190 | + | p := put(t, t.TempDir(), "k.conf", "color1\t#fd4663\nbackground\t\t#070722\n") | |
| 191 | + | got, err := Load(p) | |
| 192 | + | if err != nil || got.Palette[1] != red || got.Background != bg { | |
| 193 | + | t.Fatalf("tab-separated keys: err=%v c1=%v bg=%v", err, got.Palette[1], got.Background) | |
| 161 | 194 | } | |
| 162 | 195 | } | |
| 163 | 196 | ||
| 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") | |
| 197 | + | func TestFilesAreDeduplicated(t *testing.T) { | |
| 198 | + | d := t.TempDir() | |
| 199 | + | put(t, d, "t.conf", "color1 #fd4663\n") | |
| 200 | + | main := put(t, d, "k.conf", "include t.conf\ninclude t.conf\ninclude t.conf\n") | |
| 201 | + | got, err := Load(main) | |
| 202 | + | if err != nil || len(got.Files) != 2 { | |
| 203 | + | t.Fatalf("Files = %v (err %v), want the config and one include", got.Files, err) | |
| 167 | 204 | } | |
| 168 | 205 | } | |
| 169 | 206 | ||
| @@ -3,6 +3,7 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "os" | 4 | "os" | ||
| 5 | "path/filepath" | 5 | "path/filepath" | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| @@ -136,34 +137,70 @@ func fakeEnv(dir string, vars map[string]string) Env { | |||||
| 136 | return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }} | 137 | return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }} | ||
| 137 | } | 138 | } | ||
| 138 | 139 | ||||
| 139 | - | func TestAutoPrefersTheTerminalInUse(t *testing.T) { | 140 | + | func TestAutoReadsOnlyTheTerminalVariable(t *testing.T) { |
| 140 | d := t.TempDir() | 141 | d := t.TempDir() | ||
| 141 | put(t, d, "kitty/kitty.conf", "color1 #000001\n") | 142 | put(t, d, "kitty/kitty.conf", "color1 #000001\n") | ||
| 142 | put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n") | 143 | put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n") | ||
| 143 | - | got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": "/usr/bin/foot"})) | 144 | + | for _, value := range []string{"foot", "/usr/bin/foot", "/usr/bin/foot --server", " foot "} { |
| 144 | - | if err != nil || got.Palette[1] != red { | 145 | + | got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": value})) |
| 145 | - | t.Fatalf("$TERMINAL=foot: err=%v c1=%v", err, got.Palette[1]) | 146 | + | if err != nil || got.Palette[1] != red { |
| 147 | + | t.Errorf("TERMINAL=%q: err=%v c1=%v", value, err, got.Palette[1]) | |||
| 148 | + | } | |||
| 146 | } | 149 | } | ||
| 147 | - | put(t, d, "xdg-terminals.list", "# comment\nkitty.desktop\n") | 150 | + | // A config for another terminal must not be picked up by accident. |
| 148 | - | got, _ = Auto(fakeEnv(d, nil)) | 151 | + | if got, _ := Auto(fakeEnv(d, map[string]string{"TERMINAL": "kitty"})); got.Palette[1] != (RGB{0, 0, 1}) { |
| 149 | - | if got.Palette[1] != (RGB{0, 0, 1}) { | 152 | + | t.Errorf("TERMINAL=kitty read the wrong file: c1=%v", got.Palette[1]) |
| 150 | - | t.Fatalf("xdg-terminals.list=kitty: c1=%v", got.Palette[1]) | |||
| 151 | } | 153 | } | ||
| 152 | } | 154 | } | ||
| 153 | 155 | ||||
| 154 | - | func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) { | 156 | + | func TestAutoErrorsAreOneClearSentence(t *testing.T) { |
| 155 | d := t.TempDir() | 157 | d := t.TempDir() | ||
| 156 | - | put(t, d, "kitty/kitty.conf", "font_size 12\n") // exists but has no colors | 158 | + | put(t, d, "kitty/kitty.conf", "font_size 12\n") // no colors |
| 157 | - | put(t, d, "alacritty/alacritty.toml", "[colors.normal]\nred = '#fd4663'\n") | 159 | + | cases := map[string]struct{ env, contains string }{ |
| 158 | - | got, err := Auto(fakeEnv(d, nil)) | 160 | + | "unset": {"", "needs $TERMINAL"}, |
| 161 | + | "blank": {" ", "needs $TERMINAL"}, | |||
| 162 | + | "unknown": {"xterm", "kitty, ghostty, foot and alacritty only"}, | |||
| 163 | + | "no config": {"foot", "does not exist"}, | |||
| 164 | + | "no colors": {"kitty", "sets no colors"}, | |||
| 165 | + | } | |||
| 166 | + | for name, c := range cases { | |||
| 167 | + | _, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": c.env})) | |||
| 168 | + | if err == nil || !strings.Contains(err.Error(), c.contains) { | |||
| 169 | + | t.Errorf("%s: err = %v, want it to mention %q", name, err, c.contains) | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | // The unset message tells the user where to put the variable. | |||
| 173 | + | _, err := Auto(fakeEnv(d, nil)) | |||
| 174 | + | if err == nil || !strings.Contains(err.Error(), "environment.d") { | |||
| 175 | + | t.Errorf("unset message must name environment.d: %v", err) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestGhosttySelfReferenceTerminates(t *testing.T) { | |||
| 180 | + | d := t.TempDir() | |||
| 181 | + | self := filepath.Join(d, "config") | |||
| 182 | + | put(t, d, "config", "theme = "+self+"\npalette = 1=#fd4663\n") | |||
| 183 | + | got, err := Load(self) | |||
| 159 | if err != nil || got.Palette[1] != red { | 184 | if err != nil || got.Palette[1] != red { | ||
| 160 | - | t.Fatalf("err=%v c1=%v", err, got.Palette[1]) | 185 | + | t.Fatalf("self-referencing ghostty theme: err=%v c1=%v", err, got.Palette[1]) |
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func TestKittyKeysSeparatedByATab(t *testing.T) { | |||
| 190 | + | p := put(t, t.TempDir(), "k.conf", "color1\t#fd4663\nbackground\t\t#070722\n") | |||
| 191 | + | got, err := Load(p) | |||
| 192 | + | if err != nil || got.Palette[1] != red || got.Background != bg { | |||
| 193 | + | t.Fatalf("tab-separated keys: err=%v c1=%v bg=%v", err, got.Palette[1], got.Background) | |||
| 161 | } | 194 | } | ||
| 162 | } | 195 | } | ||
| 163 | 196 | ||||
| 164 | - | func TestAutoWithNoTerminalIsAnError(t *testing.T) { | 197 | + | func TestFilesAreDeduplicated(t *testing.T) { |
| 165 | - | if _, err := Auto(fakeEnv(t.TempDir(), nil)); err == nil { | 198 | + | d := t.TempDir() |
| 166 | - | t.Fatal("no terminal config must be an error the caller can report") | 199 | + | put(t, d, "t.conf", "color1 #fd4663\n") |
| 200 | + | main := put(t, d, "k.conf", "include t.conf\ninclude t.conf\ninclude t.conf\n") | |||
| 201 | + | got, err := Load(main) | |||
| 202 | + | if err != nil || len(got.Files) != 2 { | |||
| 203 | + | t.Fatalf("Files = %v (err %v), want the config and one include", got.Files, err) | |||
| 167 | } | 204 | } | ||
| 168 | } | 205 | } | ||
| 169 | 206 | ||||