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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | + | } | |||