Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing the palette meant editing the styles themselves. This puts a semantic token set between the two: styles name roles, a theme supplies the colours. internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the new default, and gruvbox-light) and the loader for user themes in ~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and override only what it cares about, and may shadow a built-in name to tweak it in place. Unknown keys, malformed colours and incomplete themes are refused with a message naming what went wrong. Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour terminals and honours NO_COLOR, so themes carry no fallbacks of their own. An ANSI index is still accepted for anyone who would rather follow their terminal's own palette. The 21 package-level style vars become a Styles struct on the Model, which is what rule 3 asked for all along; the four free functions in view.go take one as their first argument. The embedded bubbles components are restyled from the same tokens — otherwise a theme would leave a pink selected row and grey help text behind. Note that the table's Cell style deliberately keeps no foreground: bubbles renders cells before wrapping the row in Selected, so a colour there cuts the selection highlight short.
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
package theme
|
||||
|
||||
import "sort"
|
||||
|
||||
// The gruvbox palettes, in the author's original names. Dark uses the bright
|
||||
// variants and light the faded ones, which is what keeps each readable against
|
||||
// its own background.
|
||||
const (
|
||||
darkBg0 = "#282828"
|
||||
darkFg1 = "#ebdbb2"
|
||||
darkGray = "#928374"
|
||||
darkRed = "#fb4934"
|
||||
darkGrn = "#b8bb26"
|
||||
darkYel = "#fabd2f"
|
||||
darkBlu = "#83a598"
|
||||
darkAqua = "#8ec07c"
|
||||
darkOrng = "#fe8019"
|
||||
|
||||
lightBg0 = "#fbf1c7"
|
||||
lightFg1 = "#3c3836"
|
||||
lightFg4 = "#7c6f64"
|
||||
lightRed = "#9d0006"
|
||||
lightGrn = "#79740e"
|
||||
lightYel = "#b57614"
|
||||
lightBlu = "#076678"
|
||||
lightAqua = "#427b58"
|
||||
lightOrng = "#af3a03"
|
||||
)
|
||||
|
||||
// GruvboxDark is the default scheme. It assumes a dark terminal background:
|
||||
// themes colour foregrounds only, so the terminal supplies the canvas.
|
||||
var GruvboxDark = Theme{
|
||||
Name: "gruvbox-dark",
|
||||
|
||||
Primary: darkBlu,
|
||||
OnPrimary: darkBg0,
|
||||
Text: darkFg1,
|
||||
Muted: darkGray,
|
||||
Accent: darkOrng,
|
||||
|
||||
Firing: darkRed,
|
||||
Resolved: darkGrn,
|
||||
Error: darkRed,
|
||||
|
||||
SevCritical: darkRed,
|
||||
SevError: darkOrng,
|
||||
SevWarning: darkYel,
|
||||
SevInfo: darkAqua,
|
||||
}
|
||||
|
||||
// GruvboxLight is the same scheme against a light terminal background.
|
||||
var GruvboxLight = Theme{
|
||||
Name: "gruvbox-light",
|
||||
|
||||
Primary: lightBlu,
|
||||
OnPrimary: lightBg0,
|
||||
Text: lightFg1,
|
||||
Muted: lightFg4,
|
||||
Accent: lightOrng,
|
||||
|
||||
Firing: lightRed,
|
||||
Resolved: lightGrn,
|
||||
Error: lightRed,
|
||||
|
||||
SevCritical: lightRed,
|
||||
SevError: lightOrng,
|
||||
SevWarning: lightYel,
|
||||
SevInfo: lightAqua,
|
||||
}
|
||||
|
||||
// Default is the theme used when the config names none.
|
||||
var Default = GruvboxDark
|
||||
|
||||
var builtins = map[string]Theme{
|
||||
GruvboxDark.Name: GruvboxDark,
|
||||
GruvboxLight.Name: GruvboxLight,
|
||||
}
|
||||
|
||||
// BuiltinNames lists the compiled-in themes, sorted, for error messages and
|
||||
// documentation.
|
||||
func BuiltinNames() []string {
|
||||
names := make([]string, 0, len(builtins))
|
||||
for name := range builtins {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// rawTheme is the on-disk form. Every token is a pointer so an absent key is
|
||||
// distinguishable from an empty one, which is what lets 'extends' overwrite
|
||||
// only what the file actually mentions.
|
||||
type rawTheme struct {
|
||||
Extends *string `yaml:"extends"`
|
||||
|
||||
Primary *string `yaml:"primary"`
|
||||
OnPrimary *string `yaml:"on_primary"`
|
||||
Text *string `yaml:"text"`
|
||||
Muted *string `yaml:"muted"`
|
||||
Accent *string `yaml:"accent"`
|
||||
|
||||
Firing *string `yaml:"firing"`
|
||||
Resolved *string `yaml:"resolved"`
|
||||
Error *string `yaml:"error"`
|
||||
|
||||
SevCritical *string `yaml:"sev_critical"`
|
||||
SevError *string `yaml:"sev_error"`
|
||||
SevWarning *string `yaml:"sev_warning"`
|
||||
SevInfo *string `yaml:"sev_info"`
|
||||
}
|
||||
|
||||
// binding ties a YAML key to its raw value and the field it fills, so parsing,
|
||||
// merging and the missing-token report all walk the same list.
|
||||
type binding struct {
|
||||
key string
|
||||
src *string
|
||||
dst *lipgloss.Color
|
||||
}
|
||||
|
||||
func bindings(r *rawTheme, t *Theme) []binding {
|
||||
return []binding{
|
||||
{"primary", r.Primary, &t.Primary},
|
||||
{"on_primary", r.OnPrimary, &t.OnPrimary},
|
||||
{"text", r.Text, &t.Text},
|
||||
{"muted", r.Muted, &t.Muted},
|
||||
{"accent", r.Accent, &t.Accent},
|
||||
{"firing", r.Firing, &t.Firing},
|
||||
{"resolved", r.Resolved, &t.Resolved},
|
||||
{"error", r.Error, &t.Error},
|
||||
{"sev_critical", r.SevCritical, &t.SevCritical},
|
||||
{"sev_error", r.SevError, &t.SevError},
|
||||
{"sev_warning", r.SevWarning, &t.SevWarning},
|
||||
{"sev_info", r.SevInfo, &t.SevInfo},
|
||||
}
|
||||
}
|
||||
|
||||
// tokenKeys lists the colour keys a theme file may set, in the order they are
|
||||
// documented.
|
||||
func tokenKeys() []string {
|
||||
bs := bindings(&rawTheme{}, &Theme{})
|
||||
keys := make([]string, len(bs))
|
||||
for i, b := range bs {
|
||||
keys[i] = b.key
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func isTokenKey(k string) bool {
|
||||
if k == "extends" {
|
||||
return true
|
||||
}
|
||||
for _, want := range tokenKeys() {
|
||||
if k == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Load resolves a theme by name. An empty name is the default; otherwise a file
|
||||
// in the user's themes directory wins over a built-in of the same name, so the
|
||||
// documented way to tweak a built-in is to shadow it rather than rename it.
|
||||
func Load(name string) (Theme, error) {
|
||||
if name == "" {
|
||||
return Default, nil
|
||||
}
|
||||
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
// Built-ins do not need the disk, so a missing config directory only
|
||||
// matters for user themes.
|
||||
if t, ok := builtins[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return Theme{}, fmt.Errorf("cannot determine config directory: %w", err)
|
||||
}
|
||||
|
||||
return loadFrom(filepath.Join(dir, "terdut-tui", "themes"), name)
|
||||
}
|
||||
|
||||
func loadFrom(dir, name string) (Theme, error) {
|
||||
if strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return Theme{}, fmt.Errorf("invalid theme name %q: a theme is a bare name, not a path", name)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, name+".yaml")
|
||||
data, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return parse(name, data)
|
||||
case !os.IsNotExist(err):
|
||||
return Theme{}, fmt.Errorf("cannot read theme file %s: %w", path, err)
|
||||
}
|
||||
|
||||
if t, ok := builtins[name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
return Theme{}, fmt.Errorf("unknown theme %q\n\nBuilt-in themes: %s\nOr define your own at %s",
|
||||
name, strings.Join(BuiltinNames(), ", "), path)
|
||||
}
|
||||
|
||||
func parse(name string, data []byte) (Theme, error) {
|
||||
// Check the keys before decoding, so a typo'd token reports itself by name
|
||||
// alongside the ones that would have worked rather than surfacing yaml's
|
||||
// message about an internal Go type.
|
||||
var keys map[string]yaml.Node
|
||||
if err := yaml.Unmarshal(data, &keys); err != nil {
|
||||
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
|
||||
}
|
||||
for k := range keys {
|
||||
if !isTokenKey(k) {
|
||||
return Theme{}, fmt.Errorf("theme %q: unknown key %q\n\nValid keys: extends, %s",
|
||||
name, k, strings.Join(tokenKeys(), ", "))
|
||||
}
|
||||
}
|
||||
|
||||
var raw rawTheme
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return Theme{}, fmt.Errorf("invalid theme %q: %w", name, err)
|
||||
}
|
||||
|
||||
t := Theme{Name: name}
|
||||
if raw.Extends != nil {
|
||||
base, ok := builtins[*raw.Extends]
|
||||
if !ok {
|
||||
return Theme{}, fmt.Errorf("theme %q: 'extends' names unknown theme %q (built-ins: %s)",
|
||||
name, *raw.Extends, strings.Join(BuiltinNames(), ", "))
|
||||
}
|
||||
t = base
|
||||
t.Name = name
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, b := range bindings(&raw, &t) {
|
||||
if b.src == nil {
|
||||
if raw.Extends == nil {
|
||||
missing = append(missing, b.key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
c, err := parseColor(*b.src)
|
||||
if err != nil {
|
||||
return Theme{}, fmt.Errorf("theme %q: %s: %w", name, b.key, err)
|
||||
}
|
||||
*b.dst = c
|
||||
}
|
||||
|
||||
if len(missing) > 0 {
|
||||
return Theme{}, fmt.Errorf("theme %q is missing %s\n\nEither set every token or add 'extends: %s' to inherit the rest",
|
||||
name, strings.Join(missing, ", "), Default.Name)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
var hexColor = regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// parseColor accepts what lipgloss can actually render: a hex value, or an ANSI
|
||||
// palette index for people who would rather follow their terminal's colours.
|
||||
func parseColor(s string) (lipgloss.Color, error) {
|
||||
if hexColor.MatchString(s) {
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
// strconv.Itoa round-trips to reject "+7" and "007", which lipgloss would
|
||||
// pass to the terminal verbatim.
|
||||
if n, err := strconv.Atoi(s); err == nil && n >= 0 && n <= 255 && strconv.Itoa(n) == s {
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
return "", fmt.Errorf("invalid colour %q, want a hex value like \"#83a598\" or an ANSI index 0-255", s)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package theme
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// write drops a theme file into dir and returns the directory, so each test
|
||||
// works against its own themes directory rather than the user's.
|
||||
func write(t *testing.T, dir, name, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".yaml"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_EmptyNameIsTheDefault(t *testing.T) {
|
||||
got, err := Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != Default.Name {
|
||||
t.Errorf("got theme %q, want %q", got.Name, Default.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_BuiltinsResolveWithoutAFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range BuiltinNames() {
|
||||
got, err := loadFrom(dir, name)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: unexpected error: %v", name, err)
|
||||
}
|
||||
if got.Name != name {
|
||||
t.Errorf("got theme %q, want %q", got.Name, name)
|
||||
}
|
||||
if got.Primary == "" {
|
||||
t.Errorf("%s: primary is unset", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_ExtendsOverridesOnlyWhatIsNamed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "mine", "extends: gruvbox-dark\nprimary: \"#d3869b\"\n")
|
||||
|
||||
got, err := loadFrom(dir, "mine")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Name != "mine" {
|
||||
t.Errorf("got name %q, want %q", got.Name, "mine")
|
||||
}
|
||||
if got.Primary != "#d3869b" {
|
||||
t.Errorf("got primary %q, want the override", got.Primary)
|
||||
}
|
||||
if got.Muted != GruvboxDark.Muted {
|
||||
t.Errorf("got muted %q, want inherited %q", got.Muted, GruvboxDark.Muted)
|
||||
}
|
||||
if got.SevInfo != GruvboxDark.SevInfo {
|
||||
t.Errorf("got sev_info %q, want inherited %q", got.SevInfo, GruvboxDark.SevInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_UserFileShadowsABuiltin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "gruvbox-dark", "extends: gruvbox-dark\naccent: \"#fabd2f\"\n")
|
||||
|
||||
got, err := loadFrom(dir, "gruvbox-dark")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Accent != "#fabd2f" {
|
||||
t.Errorf("got accent %q, want the shadowing file's value", got.Accent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_WithoutExtendsEveryTokenIsRequired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "partial", "primary: \"#83a598\"\n")
|
||||
|
||||
_, err := loadFrom(dir, "partial")
|
||||
if err == nil {
|
||||
t.Fatal("expected an error for a theme missing tokens")
|
||||
}
|
||||
for _, want := range []string{"muted", "sev_info", "extends"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("error should mention %q, got: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_CompleteThemeNeedsNoExtends(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, dir, "full", `primary: "#000001"
|
||||
on_primary: "#000002"
|
||||
text: "#000003"
|
||||
muted: "#000004"
|
||||
accent: "#000005"
|
||||
firing: "#000006"
|
||||
resolved: "#000007"
|
||||
error: "#000008"
|
||||
sev_critical: "#000009"
|
||||
sev_error: "#00000a"
|
||||
sev_warning: "#00000b"
|
||||
sev_info: "#00000c"
|
||||
`)
|
||||
|
||||
got, err := loadFrom(dir, "full")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got.Primary != "#000001" || got.SevInfo != "#00000c" {
|
||||
t.Errorf("tokens not applied: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string // empty means: write no file at all
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"unknown name", "", "", "unknown theme"},
|
||||
{"unknown key", "typo", "extends: gruvbox-dark\nprimry: \"#83a598\"\n", `unknown key "primry"`},
|
||||
{"unknown key lists the valid ones", "typo2", "primry: \"#83a598\"\n", "Valid keys: extends, primary,"},
|
||||
{"bad colour", "bad", "extends: gruvbox-dark\nprimary: notacolour\n", "invalid colour"},
|
||||
{"bad colour names the token", "badkey", "extends: gruvbox-dark\nsev_warning: \"#gggggg\"\n", "sev_warning"},
|
||||
{"unknown base", "orphan", "extends: solarized\nprimary: \"#83a598\"\n", "unknown theme \"solarized\""},
|
||||
{"malformed yaml", "broken", "extends: [\n", "invalid theme"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
name := tt.file
|
||||
if name == "" {
|
||||
name = "missing"
|
||||
} else {
|
||||
write(t, dir, name, tt.body)
|
||||
}
|
||||
|
||||
_, err := loadFrom(dir, name)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Errorf("error should mention %q, got: %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFrom_RejectsPathsAsNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"../secrets", "sub/theme", ".."} {
|
||||
if _, err := loadFrom(dir, name); err == nil {
|
||||
t.Errorf("%q: expected an error", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColor(t *testing.T) {
|
||||
ok := []string{"#83a598", "#FFF", "#abc", "0", "15", "255"}
|
||||
for _, s := range ok {
|
||||
if _, err := parseColor(s); err != nil {
|
||||
t.Errorf("parseColor(%q) = %v, want no error", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []string{"", "83a598", "#ab", "#abcd", "#gggggg", "256", "-1", "+7", "007", "red"}
|
||||
for _, s := range bad {
|
||||
if _, err := parseColor(s); err == nil {
|
||||
t.Errorf("parseColor(%q) = nil, want an error", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Package theme resolves the named colour scheme the TUI renders with. A theme
|
||||
// is a flat set of semantic tokens — roles like "muted" or "firing", never hues
|
||||
// — so a new scheme is a table of colours rather than a change to the views.
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Theme is the palette the UI draws from. Every token is a foreground except
|
||||
// OnPrimary, which is the text colour for the two places that invert: the
|
||||
// active tab and the selected table row.
|
||||
//
|
||||
// Colours are truecolor hex; lipgloss downsamples them for 256- and 16-colour
|
||||
// terminals and drops them entirely under NO_COLOR, so themes do not carry
|
||||
// fallbacks of their own.
|
||||
type Theme struct {
|
||||
Name string
|
||||
|
||||
Primary lipgloss.Color // header, tab highlight, selection
|
||||
OnPrimary lipgloss.Color // text drawn on a Primary background
|
||||
Text lipgloss.Color // default emphasis foreground
|
||||
Muted lipgloss.Color // secondary text, dividers, borders
|
||||
Accent lipgloss.Color // status line, acknowledged, by-day chart
|
||||
|
||||
Firing lipgloss.Color
|
||||
Resolved lipgloss.Color
|
||||
Error lipgloss.Color
|
||||
|
||||
SevCritical lipgloss.Color
|
||||
SevError lipgloss.Color
|
||||
SevWarning lipgloss.Color
|
||||
SevInfo lipgloss.Color
|
||||
}
|
||||
Reference in New Issue
Block a user