From 52ecb2bba06d55e974600adbcd622ced4d7a8328 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 21:56:04 +0300 Subject: [PATCH] Add `theme` to lets settings --- docs/docs/changelog.md | 1 + docs/docs/settings.md | 34 ++++++++++++++++++++++++-- internal/cli/cli.go | 2 +- internal/settings/settings.go | 22 +++++++++++++++-- internal/settings/settings_test.go | 34 +++++++++++++++++++++++--- internal/theme/theme.go | 29 +++++++++++++++++++++++ internal/theme/theme_test.go | 38 ++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 internal/theme/theme_test.go diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 3bc1e7b9..30dd5c1c 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -12,6 +12,7 @@ title: Changelog * `[Changed]` Add breathing room between the `USAGE` heading and its usage block in help output. * `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping. * `[Testing]` Add golden-file tests for help and error rendering in `internal/cmd`, replacing bats format checks with snapshot comparisons. +* `[Added]` Add a `theme` user setting with `default`, `ansi`, and `synthwave` themes for lets help and styled error output. ## [0.0.61](https://github.com/lets-cli/lets/releases/tag/v0.0.61) diff --git a/docs/docs/settings.md b/docs/docs/settings.md index 78f6b6b6..02c281f7 100644 --- a/docs/docs/settings.md +++ b/docs/docs/settings.md @@ -5,7 +5,7 @@ title: Settings `lets` settings control the behavior of `lets` itself. -Use settings for things like colored output or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`. +Use settings for things like colored output, theming, or update notifications. Do not use this file for project commands or runtime env. Project behavior still belongs in `lets.yaml`. ## Settings file location @@ -48,6 +48,35 @@ Note: - this affects `lets` output itself - it does not inject `NO_COLOR` into commands from `lets.yaml` +### `theme` + +Choose the theme for `lets` styled help and error output. + +Supported values: + +- `default` +- `ansi` +- `synthwave` + +Example: + +```yaml +theme: synthwave +``` + +Environment override: + +- none + +Default: + +- `theme: default` + +Notes: + +- this affects `lets` output itself +- project commands still control their own colors + ### `upgrade_notify` Enable or disable background update notifications for interactive sessions. @@ -69,7 +98,8 @@ Default: ## Example ```yaml -no_color: true +no_color: false +theme: default upgrade_notify: false ``` diff --git a/internal/cli/cli.go b/internal/cli/cli.go index a747e2d8..4d680cee 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -111,7 +111,7 @@ func Main(version string, buildDate string) int { ctx, rootCmd, fang.WithVersion(rootCmd.Version), - fang.WithColorSchemeFunc(theme.DefaultColorScheme), + fang.WithColorSchemeFunc(theme.ColorSchemeByName(appSettings.Theme)), fang.WithErrorHandler(cmd.ErrorHandler), fang.WithHelpRenderer(cmd.HelpRenderer), ); err != nil { diff --git a/internal/settings/settings.go b/internal/settings/settings.go index fba23d20..56ea5f87 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -5,23 +5,27 @@ import ( "os" "github.com/fatih/color" + "github.com/lets-cli/lets/internal/theme" "github.com/lets-cli/lets/internal/util" "gopkg.in/yaml.v3" ) type FileSettings struct { - NoColor *bool `yaml:"no_color"` - UpgradeNotify *bool `yaml:"upgrade_notify"` + NoColor *bool `yaml:"no_color"` + Theme *string `yaml:"theme"` + UpgradeNotify *bool `yaml:"upgrade_notify"` } type Settings struct { NoColor bool + Theme string UpgradeNotify bool } func Default() Settings { return Settings{ NoColor: false, + Theme: theme.DefaultName, UpgradeNotify: true, } } @@ -63,12 +67,26 @@ func LoadFile(path string) (Settings, error) { cfg.NoColor = *fileSettings.NoColor } + if fileSettings.Theme != nil { + cfg.Theme = *fileSettings.Theme + } + if fileSettings.UpgradeNotify != nil { cfg.UpgradeNotify = *fileSettings.UpgradeNotify } applyEnvOverrides(&cfg) + if !theme.ValidName(cfg.Theme) { + return Settings{}, fmt.Errorf( + "invalid theme %q: must be one of %q, %q, %q", + cfg.Theme, + theme.DefaultName, + theme.ANSIName, + theme.SynthwaveName, + ) + } + return cfg, nil } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 45b9d4b9..5e824444 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -39,6 +39,9 @@ func TestLoadFile(t *testing.T) { if cfg.NoColor { t.Fatal("expected no_color default to be false") } + if cfg.Theme != "default" { + t.Fatalf("expected theme default to be %q, got %q", "default", cfg.Theme) + } if !cfg.UpgradeNotify { t.Fatal("expected upgrade_notify default to be true") } @@ -49,7 +52,7 @@ func TestLoadFile(t *testing.T) { unsetEnv(t, "LETS_CHECK_UPDATE") path := filepath.Join(t.TempDir(), "config.yaml") - err := os.WriteFile(path, []byte("no_color: true\nupgrade_notify: false\n"), 0o644) + err := os.WriteFile(path, []byte("no_color: true\ntheme: synthwave\nupgrade_notify: false\n"), 0o644) if err != nil { t.Fatalf("failed to write settings file: %v", err) } @@ -62,6 +65,9 @@ func TestLoadFile(t *testing.T) { if !cfg.NoColor { t.Fatal("expected no_color to be true") } + if cfg.Theme != "synthwave" { + t.Fatalf("expected theme to be %q, got %q", "synthwave", cfg.Theme) + } if cfg.UpgradeNotify { t.Fatal("expected upgrade_notify to be false") } @@ -69,7 +75,7 @@ func TestLoadFile(t *testing.T) { t.Run("env overrides file values", func(t *testing.T) { path := filepath.Join(t.TempDir(), "config.yaml") - err := os.WriteFile(path, []byte("no_color: false\nupgrade_notify: true\n"), 0o644) + err := os.WriteFile(path, []byte("no_color: false\ntheme: ansi\nupgrade_notify: true\n"), 0o644) if err != nil { t.Fatalf("failed to write settings file: %v", err) } @@ -85,11 +91,30 @@ func TestLoadFile(t *testing.T) { if !cfg.NoColor { t.Fatal("expected NO_COLOR to override settings file") } + if cfg.Theme != "ansi" { + t.Fatalf("expected theme to remain %q, got %q", "ansi", cfg.Theme) + } if cfg.UpgradeNotify { t.Fatal("expected LETS_CHECK_UPDATE to disable notifications") } }) + t.Run("rejects invalid theme", func(t *testing.T) { + unsetEnv(t, "NO_COLOR") + unsetEnv(t, "LETS_CHECK_UPDATE") + + path := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(path, []byte("theme: vaporwave\n"), 0o644) + if err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + _, err = LoadFile(path) + if err == nil { + t.Fatal("expected error") + } + }) + t.Run("rejects unknown fields", func(t *testing.T) { unsetEnv(t, "NO_COLOR") unsetEnv(t, "LETS_CHECK_UPDATE") @@ -119,7 +144,7 @@ func TestLoad(t *testing.T) { t.Fatalf("failed to create config dir: %v", err) } - err = os.WriteFile(configPath, []byte("no_color: true\n"), 0o644) + err = os.WriteFile(configPath, []byte("no_color: true\ntheme: ansi\n"), 0o644) if err != nil { t.Fatalf("failed to write settings file: %v", err) } @@ -132,6 +157,9 @@ func TestLoad(t *testing.T) { if !cfg.NoColor { t.Fatal("expected loaded no_color to be true") } + if cfg.Theme != "ansi" { + t.Fatalf("expected loaded theme to be %q, got %q", "ansi", cfg.Theme) + } } func TestApply(t *testing.T) { diff --git a/internal/theme/theme.go b/internal/theme/theme.go index c0d2a622..b4a04267 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -8,6 +8,35 @@ import ( "github.com/lets-cli/fang" ) +// Supported theme names. +const ( + DefaultName = "default" + ANSIName = "ansi" + SynthwaveName = "synthwave" +) + +// ValidName reports whether name is a supported theme. +func ValidName(name string) bool { + switch name { + case DefaultName, ANSIName, SynthwaveName: + return true + default: + return false + } +} + +// ColorSchemeByName resolves a theme name to a Fang color scheme. +func ColorSchemeByName(name string) fang.ColorSchemeFunc { + switch name { + case ANSIName: + return AnsiColorScheme + case SynthwaveName: + return SynthwaveColorScheme + default: + return DefaultColorScheme + } +} + // DefaultColorScheme is the default colorscheme. func DefaultColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { baseCyan := charmtone.Turtle diff --git a/internal/theme/theme_test.go b/internal/theme/theme_test.go new file mode 100644 index 00000000..37f54cac --- /dev/null +++ b/internal/theme/theme_test.go @@ -0,0 +1,38 @@ +package theme + +import ( + "testing" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" +) + +func TestValidName(t *testing.T) { + for _, name := range []string{DefaultName, ANSIName, SynthwaveName} { + if !ValidName(name) { + t.Fatalf("expected %q to be valid", name) + } + } + + if ValidName("vaporwave") { + t.Fatal("expected unknown theme to be invalid") + } +} + +func TestColorSchemeByName(t *testing.T) { + if got := ColorSchemeByName(DefaultName)(lipgloss.LightDark(true)).Title; got != charmtone.Ash { + t.Fatalf("expected default theme title color %v, got %v", charmtone.Ash, got) + } + + if got := ColorSchemeByName(ANSIName)(lipgloss.LightDark(true)).ErrorDetails; got != lipgloss.Red { + t.Fatalf("expected ansi theme error details color %v, got %v", lipgloss.Red, got) + } + + if got := ColorSchemeByName(SynthwaveName)(lipgloss.LightDark(true)).Title; got != charmtone.Grape { + t.Fatalf("expected synthwave theme title color %v, got %v", charmtone.Grape, got) + } + + if got := ColorSchemeByName("unknown")(lipgloss.LightDark(true)).Title; got != charmtone.Ash { + t.Fatalf("expected unknown theme to fall back to %v, got %v", charmtone.Ash, got) + } +}