chore: import upstream snapshot with attribution
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:13 +08:00
commit ead81af521
414 changed files with 73946 additions and 0 deletions
+142
View File
@@ -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")
}
}
+12
View File
@@ -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
View File
@@ -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
}
}
+125
View File
@@ -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()")
}
}
+6
View File
@@ -0,0 +1,6 @@
accent = "#73d0ff"
bright_fg = "#f3f4f5"
fg = "#cccac2"
green = "#d5ff80"
yellow = "#ffad66"
red = "#f28779"
+6
View File
@@ -0,0 +1,6 @@
accent = "#1e66f5"
bright_fg = "#4c4f69"
fg = "#8c8fa1"
green = "#40a02b"
yellow = "#df8e1d"
red = "#d20f39"
+6
View File
@@ -0,0 +1,6 @@
accent = "#89b4fa"
bright_fg = "#cdd6f4"
fg = "#9399b2"
green = "#a6e3a1"
yellow = "#f9e2af"
red = "#f38ba8"
+8
View File
@@ -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"
+8
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
accent = "#7d82d9"
bright_fg = "#ffcead"
fg = "#9a96a8"
green = "#92a593"
yellow = "#E9BB4F"
red = "#ED5B5A"
+6
View File
@@ -0,0 +1,6 @@
accent = "#7fbbb3"
bright_fg = "#d3c6aa"
fg = "#7a8478"
green = "#a7c080"
yellow = "#dbbc7f"
red = "#e67e80"
+6
View File
@@ -0,0 +1,6 @@
accent = "#205EA6"
bright_fg = "#100F0F"
fg = "#6F6E69"
green = "#879A39"
yellow = "#D0A215"
red = "#D14D41"
+6
View File
@@ -0,0 +1,6 @@
accent = "#7daea3"
bright_fg = "#d4be98"
fg = "#a89984"
green = "#a9b665"
yellow = "#d8a657"
red = "#ea6962"
+6
View File
@@ -0,0 +1,6 @@
accent = "#82FB9C"
bright_fg = "#ddf7ff"
fg = "#8e95b8"
green = "#4fe88f"
yellow = "#50f7d4"
red = "#50f872"
+6
View File
@@ -0,0 +1,6 @@
accent = "#7e9cd8"
bright_fg = "#dcd7ba"
fg = "#938aa9"
green = "#76946a"
yellow = "#c0a36e"
red = "#c34043"
+6
View File
@@ -0,0 +1,6 @@
accent = "#e68e0d"
bright_fg = "#bebebe"
fg = "#777777"
green = "#FFC107"
yellow = "#b91c1c"
red = "#D35F5F"
+6
View File
@@ -0,0 +1,6 @@
accent = "#78824b"
bright_fg = "#c2c2b0"
fg = "#666666"
green = "#5f875f"
yellow = "#b36d43"
red = "#685742"
+9
View File
@@ -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"
+6
View File
@@ -0,0 +1,6 @@
accent = "#81a1c1"
bright_fg = "#d8dee9"
fg = "#8690a0"
green = "#a3be8c"
yellow = "#ebcb8b"
red = "#bf616a"
+6
View File
@@ -0,0 +1,6 @@
accent = "#509475"
bright_fg = "#F7E8B2"
fg = "#C1C497"
green = "#549e6a"
yellow = "#459451"
red = "#FF5345"
+6
View File
@@ -0,0 +1,6 @@
accent = "#f38d70"
bright_fg = "#e6d9db"
fg = "#948a8b"
green = "#adda78"
yellow = "#f9cc6c"
red = "#fd6883"
+6
View File
@@ -0,0 +1,6 @@
accent = "#56949f"
bright_fg = "#575279"
fg = "#908caa"
green = "#286983"
yellow = "#ea9d34"
red = "#b4637a"
+6
View File
@@ -0,0 +1,6 @@
accent = "#7aa2f7"
bright_fg = "#cfc9c2"
fg = "#737aa2"
green = "#9ece6a"
yellow = "#e0af68"
red = "#f7768e"
+6
View File
@@ -0,0 +1,6 @@
accent = "#8d8d8d"
bright_fg = "#ffffff"
fg = "#8d8d8d"
green = "#b6b6b6"
yellow = "#cecece"
red = "#a4a4a4"