chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAllIncludesBuiltinThemes(t *testing.T) {
|
||||
// Point HOME somewhere empty so only embedded themes load.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
themes := LoadAll()
|
||||
if len(themes) == 0 {
|
||||
t.Fatal("LoadAll() returned no themes, expected built-in set")
|
||||
}
|
||||
|
||||
// Check a well-known theme is present (dracula ships with the project).
|
||||
var hasDracula bool
|
||||
for _, th := range themes {
|
||||
if th.Name == "dracula" {
|
||||
hasDracula = true
|
||||
if th.Accent == "" {
|
||||
t.Error("dracula theme has empty Accent — embed/parse failed")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDracula {
|
||||
t.Error("built-in themes missing dracula")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllSortedCaseInsensitive(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
|
||||
themes := LoadAll()
|
||||
for i := 1; i < len(themes); i++ {
|
||||
a := strings.ToLower(themes[i-1].Name)
|
||||
b := strings.ToLower(themes[i].Name)
|
||||
if a > b {
|
||||
t.Errorf("themes not sorted: %q before %q", themes[i-1].Name, themes[i].Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllUserThemeOverridesBuiltin(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
// Put a user override file named "dracula.toml" with a distinctive accent color.
|
||||
userDir := filepath.Join(home, ".config", "cliamp", "themes")
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
overridden := `accent = "#ff00ff"
|
||||
fg = "#123456"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(userDir, "dracula.toml"), []byte(overridden), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
themes := LoadAll()
|
||||
var got Theme
|
||||
for _, th := range themes {
|
||||
if strings.EqualFold(th.Name, "dracula") {
|
||||
got = th
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.Name == "" {
|
||||
t.Fatal("dracula theme not present after override")
|
||||
}
|
||||
if got.Accent != "#ff00ff" {
|
||||
t.Errorf("Accent = %q, want #ff00ff (user override)", got.Accent)
|
||||
}
|
||||
if got.FG != "#123456" {
|
||||
t.Errorf("FG = %q, want #123456 (user override)", got.FG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllAddsUserOnlyTheme(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
userDir := filepath.Join(home, ".config", "cliamp", "themes")
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
custom := `accent = "#abcdef"`
|
||||
if err := os.WriteFile(filepath.Join(userDir, "mytheme.toml"), []byte(custom), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
themes := LoadAll()
|
||||
var found bool
|
||||
for _, th := range themes {
|
||||
if th.Name == "mytheme" {
|
||||
found = true
|
||||
if th.Accent != "#abcdef" {
|
||||
t.Errorf("Accent = %q, want #abcdef", th.Accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("user theme mytheme not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllIgnoresNonTomlFiles(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
userDir := filepath.Join(home, ".config", "cliamp", "themes")
|
||||
if err := os.MkdirAll(userDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userDir, "notatheme.txt"), []byte("ignored"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
// Subdirectory should also be ignored.
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "nested"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll nested: %v", err)
|
||||
}
|
||||
|
||||
themes := LoadAll()
|
||||
for _, th := range themes {
|
||||
if th.Name == "notatheme" || th.Name == "nested" {
|
||||
t.Errorf("non-toml entry %q leaked into LoadAll()", th.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAllMissingUserDir(t *testing.T) {
|
||||
// HOME points at a dir where ~/.config/cliamp/themes doesn't exist.
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
themes := LoadAll()
|
||||
if len(themes) == 0 {
|
||||
t.Error("LoadAll() with missing user dir should still return built-in themes")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Unsetenv("CLIAMP_CONFIG_DIR")
|
||||
os.Unsetenv("XDG_CONFIG_HOME")
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
// Package theme handles loading and parsing color themes from TOML files.
|
||||
package theme
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"embed"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/bjarneo/cliamp/internal/appdir"
|
||||
)
|
||||
|
||||
//go:embed themes/*.toml
|
||||
var builtinThemes embed.FS
|
||||
|
||||
// DefaultName is the display name for the built-in ANSI fallback theme.
|
||||
const DefaultName = "Default - Terminal colors"
|
||||
|
||||
// Theme holds a named color scheme with hex color values.
|
||||
type Theme struct {
|
||||
Name string
|
||||
Accent string // hex
|
||||
BrightFG string
|
||||
FG string
|
||||
Green string
|
||||
Yellow string
|
||||
Red string
|
||||
}
|
||||
|
||||
// IsDefault returns true if this is the sentinel default theme (no hex values).
|
||||
func (t Theme) IsDefault() bool {
|
||||
return t.Accent == "" && t.Green == "" && t.BrightFG == ""
|
||||
}
|
||||
|
||||
// Default returns a sentinel "Default" theme with empty hex values,
|
||||
// signaling that ANSI fallback colors should be used.
|
||||
func Default() Theme {
|
||||
return Theme{Name: DefaultName}
|
||||
}
|
||||
|
||||
// Parse reads flat TOML key=value lines from r and returns a Theme.
|
||||
// Uses the same manual parsing approach as config/config.go.
|
||||
func Parse(name string, r io.Reader) (Theme, error) {
|
||||
t := Theme{Name: name}
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
val = strings.Trim(val, `"'`)
|
||||
|
||||
switch key {
|
||||
case "accent":
|
||||
t.Accent = val
|
||||
case "bright_fg":
|
||||
t.BrightFG = val
|
||||
case "fg":
|
||||
t.FG = val
|
||||
case "red":
|
||||
t.Red = val
|
||||
case "yellow":
|
||||
t.Yellow = val
|
||||
case "green":
|
||||
t.Green = val
|
||||
}
|
||||
}
|
||||
return t, scanner.Err()
|
||||
}
|
||||
|
||||
// LoadAll loads built-in themes and user custom themes from
|
||||
// ~/.config/cliamp/themes/*.toml. User themes override built-in
|
||||
// themes with the same name. Returns a sorted list.
|
||||
func LoadAll() []Theme {
|
||||
themes := make(map[string]Theme)
|
||||
|
||||
// Load embedded built-in themes (lower priority).
|
||||
loadBuiltin(themes)
|
||||
|
||||
// Load user custom themes (override built-in if same name).
|
||||
dir, err := appdir.Dir()
|
||||
if err == nil {
|
||||
loadUserDir(filepath.Join(dir, "themes"), themes)
|
||||
}
|
||||
|
||||
// Sort by name.
|
||||
result := make([]Theme, 0, len(themes))
|
||||
for _, t := range themes {
|
||||
result = append(result, t)
|
||||
}
|
||||
slices.SortFunc(result, func(a, b Theme) int {
|
||||
return cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// loadBuiltin parses the embedded theme TOML files.
|
||||
func loadBuiltin(themes map[string]Theme) {
|
||||
entries, err := builtinThemes.ReadDir("themes")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".toml")
|
||||
f, err := builtinThemes.Open("themes/" + e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := Parse(name, f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
themes[strings.ToLower(name)] = t
|
||||
}
|
||||
}
|
||||
|
||||
// loadUserDir loads themes from ~/.config/cliamp/themes/*.toml.
|
||||
func loadUserDir(dir string, themes map[string]Theme) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".toml") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".toml")
|
||||
path := filepath.Join(dir, e.Name())
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := Parse(name, f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
themes[strings.ToLower(name)] = t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefault(t *testing.T) {
|
||||
d := Default()
|
||||
if d.Name != DefaultName {
|
||||
t.Errorf("Name = %q, want %q", d.Name, DefaultName)
|
||||
}
|
||||
if !d.IsDefault() {
|
||||
t.Error("IsDefault() should be true for default theme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
theme Theme
|
||||
want bool
|
||||
}{
|
||||
{"empty hex values", Theme{Name: "Default"}, true},
|
||||
{"has accent", Theme{Name: "Custom", Accent: "#ff0000"}, false},
|
||||
{"has green", Theme{Name: "Custom", Green: "#00ff00"}, false},
|
||||
{"has bright fg", Theme{Name: "Custom", BrightFG: "#ffffff"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.theme.IsDefault(); got != tt.want {
|
||||
t.Errorf("IsDefault() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
input := `# Solarized Dark theme
|
||||
accent = "#268bd2"
|
||||
bright_fg = "#93a1a1"
|
||||
fg = "#839496"
|
||||
green = "#859900"
|
||||
yellow = "#b58900"
|
||||
red = "#dc322f"
|
||||
`
|
||||
th, err := Parse("solarized-dark", strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
|
||||
if th.Name != "solarized-dark" {
|
||||
t.Errorf("Name = %q, want solarized-dark", th.Name)
|
||||
}
|
||||
if th.Accent != "#268bd2" {
|
||||
t.Errorf("Accent = %q, want #268bd2", th.Accent)
|
||||
}
|
||||
if th.BrightFG != "#93a1a1" {
|
||||
t.Errorf("BrightFG = %q, want #93a1a1", th.BrightFG)
|
||||
}
|
||||
if th.FG != "#839496" {
|
||||
t.Errorf("FG = %q, want #839496", th.FG)
|
||||
}
|
||||
if th.Green != "#859900" {
|
||||
t.Errorf("Green = %q, want #859900", th.Green)
|
||||
}
|
||||
if th.Yellow != "#b58900" {
|
||||
t.Errorf("Yellow = %q, want #b58900", th.Yellow)
|
||||
}
|
||||
if th.Red != "#dc322f" {
|
||||
t.Errorf("Red = %q, want #dc322f", th.Red)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSkipsComments(t *testing.T) {
|
||||
input := `# comment
|
||||
accent = "#ff0000"
|
||||
# another comment
|
||||
`
|
||||
th, err := Parse("test", strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if th.Accent != "#ff0000" {
|
||||
t.Errorf("Accent = %q, want #ff0000", th.Accent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmpty(t *testing.T) {
|
||||
th, err := Parse("empty", strings.NewReader(""))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if !th.IsDefault() {
|
||||
t.Error("empty parse should produce default-like theme")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStripsQuotes(t *testing.T) {
|
||||
// Both single and double quotes should be stripped
|
||||
input := `accent = '#ff0000'
|
||||
fg = "#00ff00"
|
||||
`
|
||||
th, err := Parse("test", strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if th.Accent != "#ff0000" {
|
||||
t.Errorf("Accent = %q, want #ff0000", th.Accent)
|
||||
}
|
||||
if th.FG != "#00ff00" {
|
||||
t.Errorf("FG = %q, want #00ff00", th.FG)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsedThemeNotDefault(t *testing.T) {
|
||||
input := `accent = "#ff0000"`
|
||||
th, err := Parse("custom", strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse error: %v", err)
|
||||
}
|
||||
if th.IsDefault() {
|
||||
t.Error("theme with accent should not be IsDefault()")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#73d0ff"
|
||||
bright_fg = "#f3f4f5"
|
||||
fg = "#cccac2"
|
||||
green = "#d5ff80"
|
||||
yellow = "#ffad66"
|
||||
red = "#f28779"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#1e66f5"
|
||||
bright_fg = "#4c4f69"
|
||||
fg = "#8c8fa1"
|
||||
green = "#40a02b"
|
||||
yellow = "#df8e1d"
|
||||
red = "#d20f39"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#89b4fa"
|
||||
bright_fg = "#cdd6f4"
|
||||
fg = "#9399b2"
|
||||
green = "#a6e3a1"
|
||||
yellow = "#f9e2af"
|
||||
red = "#f38ba8"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Dracula — gothic purple with vivid spectrum.
|
||||
# The most popular terminal theme. draculatheme.com
|
||||
accent = "#bd93f9"
|
||||
bright_fg = "#f8f8f2"
|
||||
fg = "#6272a4"
|
||||
green = "#50fa7b"
|
||||
yellow = "#f1fa8c"
|
||||
red = "#ff5555"
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ember — deep warm hearth.
|
||||
# Campfire coals, darkroom safelight, analog warmth.
|
||||
accent = "#e07040"
|
||||
bright_fg = "#e8d0b8"
|
||||
fg = "#907868"
|
||||
green = "#a08858"
|
||||
yellow = "#d8a050"
|
||||
red = "#c04848"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#7d82d9"
|
||||
bright_fg = "#ffcead"
|
||||
fg = "#9a96a8"
|
||||
green = "#92a593"
|
||||
yellow = "#E9BB4F"
|
||||
red = "#ED5B5A"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#7fbbb3"
|
||||
bright_fg = "#d3c6aa"
|
||||
fg = "#7a8478"
|
||||
green = "#a7c080"
|
||||
yellow = "#dbbc7f"
|
||||
red = "#e67e80"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#205EA6"
|
||||
bright_fg = "#100F0F"
|
||||
fg = "#6F6E69"
|
||||
green = "#879A39"
|
||||
yellow = "#D0A215"
|
||||
red = "#D14D41"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#7daea3"
|
||||
bright_fg = "#d4be98"
|
||||
fg = "#a89984"
|
||||
green = "#a9b665"
|
||||
yellow = "#d8a657"
|
||||
red = "#ea6962"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#82FB9C"
|
||||
bright_fg = "#ddf7ff"
|
||||
fg = "#8e95b8"
|
||||
green = "#4fe88f"
|
||||
yellow = "#50f7d4"
|
||||
red = "#50f872"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#7e9cd8"
|
||||
bright_fg = "#dcd7ba"
|
||||
fg = "#938aa9"
|
||||
green = "#76946a"
|
||||
yellow = "#c0a36e"
|
||||
red = "#c34043"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#e68e0d"
|
||||
bright_fg = "#bebebe"
|
||||
fg = "#777777"
|
||||
green = "#FFC107"
|
||||
yellow = "#b91c1c"
|
||||
red = "#D35F5F"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#78824b"
|
||||
bright_fg = "#c2c2b0"
|
||||
fg = "#666666"
|
||||
green = "#5f875f"
|
||||
yellow = "#b36d43"
|
||||
red = "#685742"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Blade Runner 1982 — Neon noir.
|
||||
# Hot pink neon signs, cyan rain reflections, amber instrument readouts.
|
||||
# Cronenweth's Los Angeles: perpetual night, wet streets, practical light.
|
||||
accent = "#e8609a"
|
||||
bright_fg = "#b8c4d0"
|
||||
fg = "#758494"
|
||||
green = "#4eb8a8"
|
||||
yellow = "#d4a040"
|
||||
red = "#c85070"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#81a1c1"
|
||||
bright_fg = "#d8dee9"
|
||||
fg = "#8690a0"
|
||||
green = "#a3be8c"
|
||||
yellow = "#ebcb8b"
|
||||
red = "#bf616a"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#509475"
|
||||
bright_fg = "#F7E8B2"
|
||||
fg = "#C1C497"
|
||||
green = "#549e6a"
|
||||
yellow = "#459451"
|
||||
red = "#FF5345"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#f38d70"
|
||||
bright_fg = "#e6d9db"
|
||||
fg = "#948a8b"
|
||||
green = "#adda78"
|
||||
yellow = "#f9cc6c"
|
||||
red = "#fd6883"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#56949f"
|
||||
bright_fg = "#575279"
|
||||
fg = "#908caa"
|
||||
green = "#286983"
|
||||
yellow = "#ea9d34"
|
||||
red = "#b4637a"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#7aa2f7"
|
||||
bright_fg = "#cfc9c2"
|
||||
fg = "#737aa2"
|
||||
green = "#9ece6a"
|
||||
yellow = "#e0af68"
|
||||
red = "#f7768e"
|
||||
@@ -0,0 +1,6 @@
|
||||
accent = "#8d8d8d"
|
||||
bright_fg = "#ffffff"
|
||||
fg = "#8d8d8d"
|
||||
green = "#b6b6b6"
|
||||
yellow = "#cecece"
|
||||
red = "#a4a4a4"
|
||||
Reference in New Issue
Block a user