024dc095a5
T used to silently cycle Model.activeTeamID through the caller's teams with no visible list of choices. It now opens a full picker (modelled on the existing user picker) listing every team plus "All teams", each with a stable identity colour from a new six-colour theme palette. The same colour now also shows as a bullet next to "team: <name>" in the header, so the active team stays visible without opening the picker. Adds an Identity palette to the theme package (six hues, skipping the ones that already mean firing/critical), Styles.TeamColor to pick one by team id, and identity_1..6 as theme-file tokens alongside the existing twelve so a fully custom theme can still set every token.
210 lines
6.0 KiB
Go
210 lines
6.0 KiB
Go
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"`
|
|
|
|
Identity1 *string `yaml:"identity_1"`
|
|
Identity2 *string `yaml:"identity_2"`
|
|
Identity3 *string `yaml:"identity_3"`
|
|
Identity4 *string `yaml:"identity_4"`
|
|
Identity5 *string `yaml:"identity_5"`
|
|
Identity6 *string `yaml:"identity_6"`
|
|
}
|
|
|
|
// 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},
|
|
{"identity_1", r.Identity1, &t.Identity[0]},
|
|
{"identity_2", r.Identity2, &t.Identity[1]},
|
|
{"identity_3", r.Identity3, &t.Identity[2]},
|
|
{"identity_4", r.Identity4, &t.Identity[3]},
|
|
{"identity_5", r.Identity5, &t.Identity[4]},
|
|
{"identity_6", r.Identity6, &t.Identity[5]},
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|