From 4a579bdbc6e9077d860cf765710cf8150d8a615b Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Thu, 20 Aug 2026 11:06:36 +0200 Subject: [PATCH] Colour themes, defaulting to gruvbox dark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 11 +- README.md | 39 ++++++ internal/config/config.go | 5 +- internal/theme/builtin.go | 88 ++++++++++++++ internal/theme/load.go | 196 ++++++++++++++++++++++++++++++ internal/theme/load_test.go | 180 +++++++++++++++++++++++++++ internal/theme/theme.go | 32 +++++ internal/tui/model.go | 40 +++--- internal/tui/model_test.go | 5 +- internal/tui/styles.go | 208 +++++++++++++++++++++---------- internal/tui/styles_test.go | 131 ++++++++++++++++++++ internal/tui/update_test.go | 5 +- internal/tui/view.go | 236 ++++++++++++++++++------------------ internal/tui/view_test.go | 37 +++--- main.go | 9 +- 15 files changed, 996 insertions(+), 226 deletions(-) create mode 100644 internal/theme/builtin.go create mode 100644 internal/theme/load.go create mode 100644 internal/theme/load_test.go create mode 100644 internal/theme/theme.go create mode 100644 internal/tui/styles_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 0247d01..5f1e9e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,12 +28,13 @@ non-destructive "not now" alternative. main.go CLI entry point: flags, config load, health check, start TUI internal/api/client.go REST API client — one method per endpoint internal/config/config.go Config loader (~/.config/terdut-tui/config.yaml) +internal/theme/ Colour themes: semantic tokens, built-ins, user file loader internal/tui/ Bubbletea UI model.go Model struct, mode/section constants, Init(), tea.Cmd constructors update.go Update() — dispatch only, no API calls inline view.go View() — pure rendering keys.go keyMap (bubbles/key pattern) - styles.go All lipgloss styles + styles.go Styles struct — every lipgloss style, built from a theme internal/updater/updater.go Self-update via GitHub Releases ``` @@ -43,6 +44,9 @@ internal/updater/updater.go Self-update via GitHub Releases 2. **`View()` is pure** — no side effects, no state mutations. 3. **All state in `Model`** — no globals. 4. **All styles in `styles.go`** — never use lipgloss inline in `view.go`. + Styles live on `Model.styles`, built once by `newStyles(theme.Theme)`; the + handful of free functions in `view.go` take a `Styles` as their first + argument. No colour literal appears outside `internal/theme`. ## Config @@ -52,8 +56,13 @@ Location: `~/.config/terdut-tui/config.yaml` server_url: https://terdut.example.com api_key: <64-char hex key> refresh_interval: 30 # seconds, optional, default 30 +theme: gruvbox-dark # optional, default gruvbox-dark ``` +Built-in themes are `gruvbox-dark` and `gruvbox-light`; user themes are YAML +files in `~/.config/terdut-tui/themes/`, optionally `extends:`-ing a built-in. +See the README for the token list. + The API key is a one-time secret generated by terdut-server (`POST /api/users/{id}/api-keys`). ## Running diff --git a/README.md b/README.md index ced732a..d920943 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,49 @@ Create `~/.config/terdut-tui/config.yaml`: server_url: https://terdut.example.com api_key: refresh_interval: 30 # seconds, optional +theme: gruvbox-dark # optional, this is the default ``` The API key is generated in terdut-server. See the server documentation for how to bootstrap a user and issue an API key. +## Themes + +Two themes ship with the client: `gruvbox-dark` (the default) and +`gruvbox-light`. Both colour foregrounds only — the terminal supplies the +background — so pick the one that matches the background you already run. + +To make your own, drop a file in `~/.config/terdut-tui/themes/` and name it in +`theme:`. `extends` inherits a built-in, so a file only has to list what it +changes: + +```yaml +# ~/.config/terdut-tui/themes/mine.yaml +extends: gruvbox-dark +primary: "#d3869b" +accent: "#fabd2f" +``` + +A file may shadow a built-in name — `themes/gruvbox-dark.yaml` is how you tweak +the default without renaming it. + +Without `extends`, every token must be set. The twelve are: + +| Token | Where it shows | +|---|---| +| `primary` | header, active tab, selected row, cursors | +| `on_primary` | text drawn *on* `primary` — the active tab and selected row | +| `text` | incident titles and other emphasis | +| `muted` | secondary text, dividers, footer, table headers | +| `accent` | status line, acknowledged incidents, the by-day chart | +| `firing` | firing alerts, triggered incidents, the by-hour chart | +| `resolved` | resolved alerts and incidents, the top-alerts chart | +| `error` | error banners | +| `sev_critical`, `sev_error`, `sev_warning`, `sev_info` | the `severity` label | + +Values are hex (`#83a598` or `#abc`) or an ANSI palette index (`0`–`255`) if you +would rather follow your terminal's own colours. Colours are downsampled +automatically on 256- and 16-colour terminals, and `NO_COLOR` is honoured. + ## Usage ``` diff --git a/internal/config/config.go b/internal/config/config.go index 92273a1..0129461 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,12 +15,14 @@ type Config struct { ServerURL string APIKey string RefreshInterval time.Duration + Theme string } type rawConfig struct { ServerURL string `yaml:"server_url"` APIKey string `yaml:"api_key"` RefreshInterval int `yaml:"refresh_interval,omitempty"` // seconds + Theme string `yaml:"theme,omitempty"` } func Load() (*Config, error) { @@ -33,7 +35,7 @@ func Load() (*Config, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: ", path) + return nil, fmt.Errorf("config file not found at %s\n\nCreate it with:\n server_url: https://terdut.example.com\n api_key: \n theme: gruvbox-dark # optional", path) } return nil, fmt.Errorf("cannot read config file: %w", err) } @@ -59,5 +61,6 @@ func Load() (*Config, error) { ServerURL: raw.ServerURL, APIKey: raw.APIKey, RefreshInterval: interval, + Theme: raw.Theme, }, nil } diff --git a/internal/theme/builtin.go b/internal/theme/builtin.go new file mode 100644 index 0000000..1682476 --- /dev/null +++ b/internal/theme/builtin.go @@ -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 +} diff --git a/internal/theme/load.go b/internal/theme/load.go new file mode 100644 index 0000000..26f69b7 --- /dev/null +++ b/internal/theme/load.go @@ -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) +} diff --git a/internal/theme/load_test.go b/internal/theme/load_test.go new file mode 100644 index 0000000..3787810 --- /dev/null +++ b/internal/theme/load_test.go @@ -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) + } + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..d2e41e5 --- /dev/null +++ b/internal/theme/theme.go @@ -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 +} diff --git a/internal/tui/model.go b/internal/tui/model.go index ffe4e1a..86b211c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,12 +5,12 @@ import ( "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" ) // ── Enums ────────────────────────────────────────────────────────────────── @@ -244,12 +244,14 @@ type Model struct { apiKeyRevokeInput textinput.Model revealedAPIKey api.APIKey - help help.Model - keys keyMap + help help.Model + keys keyMap + styles Styles } -func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration) Model { - ts := defaultTableStyles() +func NewModel(client *api.Client, serverURL string, refreshInterval time.Duration, th theme.Theme) Model { + st := newStyles(th) + ts := st.Table() incidentT := table.New(table.WithFocused(true)) incidentT.SetStyles(ts) @@ -301,6 +303,15 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio revokeIn.Placeholder = "integer key ID" revokeIn.CharLimit = 20 + for _, in := range []*textinput.Model{ + ¬eIn, &snoozeIn, &usernameIn, &emailIn, &topicIn, &keyNameIn, &revokeIn, + } { + *in = st.Input(*in) + } + + helpModel := help.New() + helpModel.Styles = st.Help() + now := time.Now().UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) weekday := int(today.Weekday()) @@ -333,8 +344,9 @@ func NewModel(client *api.Client, serverURL string, refreshInterval time.Duratio ntfyTopicInput: topicIn, apiKeyNameInput: keyNameIn, apiKeyRevokeInput: revokeIn, - help: help.New(), + help: helpModel, keys: keys, + styles: st, } } @@ -344,16 +356,6 @@ func (m Model) Init() tea.Cmd { // ── Table rebuilders ─────────────────────────────────────────────────────── -func defaultTableStyles() table.Styles { - s := table.DefaultStyles() - s.Header = s.Header.Bold(true) - s.Selected = s.Selected. - Foreground(lipgloss.Color("0")). - Background(colorPrimary). - Bold(true) - return s -} - // setRows replaces a table's rows and keeps its cursor in a state the rest of // this package can rely on: valid whenever the table has any rows at all. // @@ -433,16 +435,16 @@ func (m *Model) refreshDetailContent() { return } if m.mode == modeAlertDetail { - m.detailViewport.SetContent(buildAlertDetailContent(m.selectedAlert, m.width)) + m.detailViewport.SetContent(buildAlertDetailContent(m.styles, m.selectedAlert, m.width)) return } m.detailViewport.SetContent( - buildIncidentDetailContent(m.selectedIncident, m.timeline, m.noteCursor, m.width)) + buildIncidentDetailContent(m.styles, m.selectedIncident, m.timeline, m.noteCursor, m.width)) } func (m *Model) refreshStatsContent() { m.statsViewport.SetContent( - buildStatsContent(m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) + buildStatsContent(m.styles, m.incidentStats, m.topAlerts, m.hourStats, m.dayStats, m.width)) } func (m Model) statsViewportHeight() int { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 8c9cb9a..cf52263 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -5,6 +5,7 @@ import ( "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" "github.com/charmbracelet/bubbles/table" ) @@ -177,7 +178,7 @@ func TestAlertRows_ShowIncidentLink(t *testing.T) { func TestUserManageRows_ShowMissingTopic(t *testing.T) { topic := "terdut-niklas" empty := "" - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.width, m.height = 120, 40 m.users = []api.User{ {ID: 1, Username: "niklas", NtfyTopic: &topic}, @@ -283,7 +284,7 @@ func TestBuildScheduleDays(t *testing.T) { // scheduledWeek builds a model showing the week of 2026-07-27 with the given // entries already on the rota. func scheduledWeek(entries []api.ScheduleEntry) Model { - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.width, m.height = 120, 40 m.connected = true m.activeSection = sectionSchedule diff --git a/internal/tui/styles.go b/internal/tui/styles.go index ea52c17..f7f25de 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -4,96 +4,172 @@ import ( "strings" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/lipgloss" ) -var ( - colorPrimary = lipgloss.Color("69") // blue - colorMuted = lipgloss.Color("240") // gray - colorFiring = lipgloss.Color("196") // red - colorResolved = lipgloss.Color("70") // green - colorAccent = lipgloss.Color("214") // orange +// Styles is every style the views draw with, built once from a theme and held +// on the Model. Nothing here reads a colour literal: the theme is the only +// place a colour is named. +type Styles struct { + Header lipgloss.Style + TabActive lipgloss.Style + TabInactive lipgloss.Style + Footer lipgloss.Style + Status lipgloss.Style - colorSevCritical = lipgloss.Color("196") // red - colorSevError = lipgloss.Color("202") // dark orange - colorSevWarning = lipgloss.Color("214") // orange - colorSevInfo = lipgloss.Color("39") // cyan - - styleHeader = lipgloss.NewStyle(). - Bold(true). - Foreground(colorPrimary). - Padding(0, 1) - - styleTabActive = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("0")). - Background(colorPrimary). - Padding(0, 2) - - styleTabInactive = lipgloss.NewStyle(). - Foreground(colorMuted). - Padding(0, 2) - - styleFooter = lipgloss.NewStyle(). - Foreground(colorMuted) - - styleStatus = lipgloss.NewStyle(). - Foreground(colorAccent). - Bold(true) - - styleError = lipgloss.NewStyle(). - Foreground(colorFiring). - Bold(true) - - styleFiring = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) - styleResolved = lipgloss.NewStyle().Foreground(colorResolved) - styleMuted = lipgloss.NewStyle().Foreground(colorMuted) - styleAlertName = lipgloss.NewStyle().Bold(true) - styleBold = lipgloss.NewStyle().Bold(true) - styleSelected = lipgloss.NewStyle().Foreground(colorPrimary).Bold(true) - styleAccent = lipgloss.NewStyle().Foreground(colorAccent) + Error lipgloss.Style + Firing lipgloss.Style + Resolved lipgloss.Style + Muted lipgloss.Style + Accent lipgloss.Style + AlertName lipgloss.Style + Bold lipgloss.Style + Selected lipgloss.Style // Incident status. Triggered is unclaimed work and reads as loudly as a // firing alert; acknowledged means somebody has it. - styleTriggered = lipgloss.NewStyle().Foreground(colorFiring).Bold(true) - styleAcknowledged = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) - styleSnoozed = lipgloss.NewStyle().Foreground(colorMuted).Italic(true) + Triggered lipgloss.Style + Acknowledged lipgloss.Style + Snoozed lipgloss.Style // Severity, over the conventional Alertmanager label values. - styleSevCritical = lipgloss.NewStyle().Foreground(colorSevCritical).Bold(true) - styleSevError = lipgloss.NewStyle().Foreground(colorSevError).Bold(true) - styleSevWarning = lipgloss.NewStyle().Foreground(colorSevWarning) - styleSevInfo = lipgloss.NewStyle().Foreground(colorSevInfo) -) + SevCritical lipgloss.Style + SevError lipgloss.Style + SevWarning lipgloss.Style + SevInfo lipgloss.Style -// severityStyle picks the style for a severity label, falling back to muted for + theme theme.Theme +} + +func newStyles(t theme.Theme) Styles { + return Styles{ + Header: lipgloss.NewStyle(). + Bold(true). + Foreground(t.Primary). + Padding(0, 1), + + TabActive: lipgloss.NewStyle(). + Bold(true). + Foreground(t.OnPrimary). + Background(t.Primary). + Padding(0, 2), + + TabInactive: lipgloss.NewStyle(). + Foreground(t.Muted). + Padding(0, 2), + + Footer: lipgloss.NewStyle().Foreground(t.Muted), + + Status: lipgloss.NewStyle(). + Foreground(t.Accent). + Bold(true), + + Error: lipgloss.NewStyle(). + Foreground(t.Error). + Bold(true), + + Firing: lipgloss.NewStyle().Foreground(t.Firing).Bold(true), + Resolved: lipgloss.NewStyle().Foreground(t.Resolved), + Muted: lipgloss.NewStyle().Foreground(t.Muted), + Accent: lipgloss.NewStyle().Foreground(t.Accent), + AlertName: lipgloss.NewStyle().Foreground(t.Text).Bold(true), + Bold: lipgloss.NewStyle().Foreground(t.Text).Bold(true), + Selected: lipgloss.NewStyle().Foreground(t.Primary).Bold(true), + + Triggered: lipgloss.NewStyle().Foreground(t.Firing).Bold(true), + Acknowledged: lipgloss.NewStyle().Foreground(t.Accent).Bold(true), + Snoozed: lipgloss.NewStyle().Foreground(t.Muted).Italic(true), + + SevCritical: lipgloss.NewStyle().Foreground(t.SevCritical).Bold(true), + SevError: lipgloss.NewStyle().Foreground(t.SevError).Bold(true), + SevWarning: lipgloss.NewStyle().Foreground(t.SevWarning), + SevInfo: lipgloss.NewStyle().Foreground(t.SevInfo), + + theme: t, + } +} + +// Severity picks the style for a severity label, falling back to muted for // values this client does not recognise rather than dropping them. -func severityStyle(severity string) lipgloss.Style { +func (s Styles) Severity(severity string) lipgloss.Style { switch strings.ToLower(severity) { case "critical": - return styleSevCritical + return s.SevCritical case "error": - return styleSevError + return s.SevError case "warning": - return styleSevWarning + return s.SevWarning case "info": - return styleSevInfo + return s.SevInfo default: - return styleMuted + return s.Muted } } -// incidentStatusStyle picks the style for an incident status, falling back to -// muted for statuses added after this client was built. -func incidentStatusStyle(status string) lipgloss.Style { +// IncidentStatus picks the style for an incident status, falling back to muted +// for statuses added after this client was built. +func (s Styles) IncidentStatus(status string) lipgloss.Style { switch status { case api.StatusTriggered: - return styleTriggered + return s.Triggered case api.StatusAcknowledged: - return styleAcknowledged + return s.Acknowledged case api.StatusResolved: - return styleResolved + return s.Resolved default: - return styleMuted + return s.Muted } } + +// ── Embedded bubbles components ──────────────────────────────────────────── +// +// Each ships its own hardcoded palette, so a theme that stopped at this +// package's own styles would leave a pink selected row and grey help text +// behind. These three restyle them from the same tokens. + +// Table styles the six tables. Padding comes from the bubbles defaults; only +// the colours are ours. +func (s Styles) Table() table.Styles { + ts := table.DefaultStyles() + ts.Header = ts.Header.Foreground(s.theme.Muted).Bold(true) + // Cell deliberately keeps no foreground: bubbles renders each cell before + // wrapping the whole row in Selected, so a colour here would emit a reset + // mid-row and cut the selection highlight short. + ts.Selected = ts.Selected. + Foreground(s.theme.OnPrimary). + Background(s.theme.Primary). + Bold(true) + return ts +} + +// Help styles the key hints in the footer. +func (s Styles) Help() help.Styles { + key := lipgloss.NewStyle().Foreground(s.theme.Text) + desc := lipgloss.NewStyle().Foreground(s.theme.Muted) + sep := lipgloss.NewStyle().Foreground(s.theme.Muted) + + return help.Styles{ + Ellipsis: sep, + ShortKey: key, + ShortDesc: desc, + ShortSeparator: sep, + FullKey: key, + FullDesc: desc, + FullSeparator: sep, + } +} + +// Input styles a text input and returns it, so NewModel can wrap each one as +// it is built. +func (s Styles) Input(ti textinput.Model) textinput.Model { + ti.PromptStyle = lipgloss.NewStyle().Foreground(s.theme.Primary) + ti.TextStyle = lipgloss.NewStyle().Foreground(s.theme.Text) + ti.PlaceholderStyle = lipgloss.NewStyle().Foreground(s.theme.Muted) + ti.CompletionStyle = lipgloss.NewStyle().Foreground(s.theme.Muted) + ti.Cursor.Style = lipgloss.NewStyle().Foreground(s.theme.Primary) + return ti +} diff --git a/internal/tui/styles_test.go b/internal/tui/styles_test.go new file mode 100644 index 0000000..0fd3847 --- /dev/null +++ b/internal/tui/styles_test.go @@ -0,0 +1,131 @@ +package tui + +import ( + "testing" + + "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/lipgloss" +) + +// Assertions here read the colours off the styles rather than off rendered +// output: under `go test` stdout is not a TTY, so lipgloss strips every escape +// sequence and rendered strings would all compare equal. + +func wantFg(t *testing.T, s lipgloss.Style, want lipgloss.Color, what string) { + t.Helper() + if got := s.GetForeground(); got != want { + t.Errorf("%s foreground = %v, want %v", what, got, want) + } +} + +func TestStyles_TokensReachTheStyles(t *testing.T) { + th := theme.GruvboxDark + s := newStyles(th) + + wantFg(t, s.Header, th.Primary, "header") + wantFg(t, s.Firing, th.Firing, "firing") + wantFg(t, s.Resolved, th.Resolved, "resolved") + wantFg(t, s.Muted, th.Muted, "muted") + wantFg(t, s.Status, th.Accent, "status") + wantFg(t, s.Error, th.Error, "error") + wantFg(t, s.SevWarning, th.SevWarning, "sev warning") + + // The two inverted spots need the pair, not just a foreground. + wantFg(t, s.TabActive, th.OnPrimary, "active tab") + if got := s.TabActive.GetBackground(); got != th.Primary { + t.Errorf("active tab background = %v, want %v", got, th.Primary) + } + + // Attributes the old package-level styles carried must survive. + if !s.Firing.GetBold() { + t.Error("firing lost its bold") + } + if !s.Snoozed.GetItalic() { + t.Error("snoozed lost its italic") + } + if top, right, bottom, left := s.TabActive.GetPadding(); top != 0 || right != 2 || bottom != 0 || left != 2 { + t.Errorf("active tab padding = %d %d %d %d, want 0 2 0 2", top, right, bottom, left) + } +} + +func TestStyles_EachBuiltinIsFullyPopulated(t *testing.T) { + for _, th := range []theme.Theme{theme.GruvboxDark, theme.GruvboxLight} { + s := newStyles(th) + for what, style := range map[string]lipgloss.Style{ + "header": s.Header, "tab inactive": s.TabInactive, "footer": s.Footer, + "status": s.Status, "error": s.Error, "firing": s.Firing, + "resolved": s.Resolved, "muted": s.Muted, "accent": s.Accent, + "alert name": s.AlertName, "bold": s.Bold, "selected": s.Selected, + "triggered": s.Triggered, "acknowledged": s.Acknowledged, "snoozed": s.Snoozed, + "sev critical": s.SevCritical, "sev error": s.SevError, + "sev warning": s.SevWarning, "sev info": s.SevInfo, + } { + if style.GetForeground() == (lipgloss.NoColor{}) { + t.Errorf("%s: %s has no foreground", th.Name, what) + } + } + } +} + +func TestStyles_SeverityFallsBackToMuted(t *testing.T) { + s := newStyles(theme.GruvboxDark) + + for _, sev := range []string{"critical", "CRITICAL", "error", "warning", "info"} { + if s.Severity(sev).GetForeground() == s.Muted.GetForeground() { + t.Errorf("severity %q should have its own colour", sev) + } + } + for _, sev := range []string{"", "page", "unknown"} { + if s.Severity(sev).GetForeground() != s.Muted.GetForeground() { + t.Errorf("severity %q should fall back to muted", sev) + } + } +} + +func TestStyles_IncidentStatusFallsBackToMuted(t *testing.T) { + s := newStyles(theme.GruvboxDark) + + for _, status := range []string{api.StatusTriggered, api.StatusAcknowledged, api.StatusResolved} { + if s.IncidentStatus(status).GetForeground() == s.Muted.GetForeground() { + t.Errorf("status %q should have its own colour", status) + } + } + if s.IncidentStatus("invented-later").GetForeground() != s.Muted.GetForeground() { + t.Error("an unknown status should fall back to muted") + } +} + +// The bubbles components ship their own palettes — a pink selected row, grey +// help text, a 240 placeholder. These check we replaced them. +func TestStyles_BubblesComponentsFollowTheTheme(t *testing.T) { + th := theme.GruvboxDark + s := newStyles(th) + + ts := s.Table() + wantFg(t, ts.Selected, th.OnPrimary, "table selection") + if got := ts.Selected.GetBackground(); got != th.Primary { + t.Errorf("table selection background = %v, want %v", got, th.Primary) + } + wantFg(t, ts.Header, th.Muted, "table header") + if _, right, _, left := ts.Cell.GetPadding(); right != 1 || left != 1 { + t.Error("table cell padding was lost") + } + // A foreground on Cell would emit a reset mid-row and truncate the + // selection highlight, so it must stay unset. + if ts.Cell.GetForeground() != (lipgloss.NoColor{}) { + t.Error("table cells must not carry a foreground") + } + + h := s.Help() + wantFg(t, h.ShortKey, th.Text, "help key") + wantFg(t, h.ShortDesc, th.Muted, "help description") + wantFg(t, h.FullSeparator, th.Muted, "help separator") + + in := s.Input(textinput.New()) + wantFg(t, in.PlaceholderStyle, th.Muted, "input placeholder") + wantFg(t, in.TextStyle, th.Text, "input text") + wantFg(t, in.PromptStyle, th.Primary, "input prompt") + wantFg(t, in.Cursor.Style, th.Primary, "input cursor") +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 1091aac..65f5dcd 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -6,6 +6,7 @@ import ( "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" tea "github.com/charmbracelet/bubbletea" ) @@ -31,7 +32,7 @@ func press(t *testing.T, m Model, key string) (Model, tea.Cmd) { // sized returns a connected model with a usable window, which most handlers need. func sized() Model { - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.width, m.height = 120, 40 m.connected = true return m @@ -690,7 +691,7 @@ func containsAll(s string, subs ...string) bool { // So this test must NOT touch the cursor. It reproduces the real order of // events: size first, data second, keys third. func TestSchedule_AssignWeekAfterStartupSizingDoesNotPanic(t *testing.T) { - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.connected = true m.activeSection = sectionSchedule m.scheduleWindow = time.Date(2026, 7, 27, 0, 0, 0, 0, time.UTC) diff --git a/internal/tui/view.go b/internal/tui/view.go index 452bc37..48f214a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -26,8 +26,8 @@ func (m Model) View() string { } func (m Model) renderHeader() string { - title := styleHeader.Render("terdut-tui") - right := styleMuted.Render(m.serverURL) + title := m.styles.Header.Render("terdut-tui") + right := m.styles.Muted.Render(m.serverURL) return spread(title, right, m.width) } @@ -35,31 +35,31 @@ func (m Model) renderTabs() string { var tabs []string for i, name := range sectionNames { if section(i) == m.activeSection { - tabs = append(tabs, styleTabActive.Render(name)) + tabs = append(tabs, m.styles.TabActive.Render(name)) } else { - tabs = append(tabs, styleTabInactive.Render(name)) + tabs = append(tabs, m.styles.TabInactive.Render(name)) } } - sep := styleMuted.Render(strings.Repeat("─", m.width)) + sep := m.styles.Muted.Render(strings.Repeat("─", m.width)) return strings.Join(tabs, "") + "\n" + sep } func (m Model) renderBody() string { if m.err != nil { - return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) + - "\n" + styleMuted.Render(" Press r to retry.") + return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) + + "\n" + m.styles.Muted.Render(" Press r to retry.") } if !m.connected { - return "\n" + styleMuted.Render(" Connecting…") + return "\n" + m.styles.Muted.Render(" Connecting…") } switch m.mode { case modeIncidentDetail, modeAlertDetail: return m.renderDetail() case modeNote: - return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View()) + return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View()) case modeSnooze: - return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View()) + return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View()) case modeConfirm: switch m.confirmTarget { case confirmDeleteNote, confirmResolveIncident: @@ -90,9 +90,9 @@ func (m Model) renderBody() string { func (m Model) renderFooter() string { withStatus := func(actions string) string { - rendered := styleFooter.Render(actions) + rendered := m.styles.Footer.Render(actions) if m.statusMsg != "" { - return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered + return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered } return "\n" + rendered } @@ -108,13 +108,13 @@ func (m Model) renderFooter() string { return withStatus(" i·open incident esc·back") case modeNote: - return "\n" + styleFooter.Render(" enter·submit esc·cancel") + return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel") case modeSnooze: - return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") + return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)") case modeConfirm: - return "\n" + styleError.Render(" "+m.confirmPrompt()) + return "\n" + m.styles.Error.Render(" "+m.confirmPrompt()) case modeUserPicker: if m.pickerTarget == pickerIncidentAssignee { @@ -159,7 +159,7 @@ func (m Model) renderFooter() string { case sectionUsers: return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit") } - return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp())) + return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp())) } } @@ -241,9 +241,9 @@ func (m Model) renderIncidents() string { var content string switch { case m.loading && len(m.incidents) == 0: - content = styleMuted.Render(" Loading incidents…") + content = m.styles.Muted.Render(" Loading incidents…") case len(m.incidents) == 0: - content = styleMuted.Render( + content = m.styles.Muted.Render( fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter))) default: content = m.incidentTable.View() @@ -256,9 +256,9 @@ func (m Model) renderAlerts() string { var content string switch { case m.loading && len(m.alerts) == 0: - content = styleMuted.Render(" Loading alerts…") + content = m.styles.Muted.Render(" Loading alerts…") case len(m.alerts) == 0: - content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) + content = m.styles.Muted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter))) default: content = m.alertTable.View() } @@ -267,10 +267,10 @@ func (m Model) renderAlerts() string { func (m Model) renderArchived() string { if m.archivedLoading { - return "\n" + styleMuted.Render(" Loading archived incidents…") + return "\n" + m.styles.Muted.Render(" Loading archived incidents…") } if len(m.archivedIncidents) == 0 { - return "\n" + styleMuted.Render(" No archived incidents.") + return "\n" + m.styles.Muted.Render(" No archived incidents.") } return "\n" + m.archivedTable.View() } @@ -286,12 +286,12 @@ func (m Model) renderIncidentStatsBar() string { mttr = humanSeconds(m.incidentStats.MTTRSeconds) } left := fmt.Sprintf(" %s %s %s %s", - styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)), - styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)), - styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), - styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), + m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)), + m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)), + m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)), + m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)), ) - right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) + right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter))) return spread(left, right, m.width) } @@ -304,10 +304,10 @@ func (m Model) renderAlertStatsBar() string { } left := fmt.Sprintf(" Total: %d %s %s", total, - styleFiring.Render(fmt.Sprintf("Firing: %d", firing)), - styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)), + m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)), + m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)), ) - right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) + right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter))) return spread(left, right, m.width) } @@ -324,20 +324,20 @@ func spread(left, right string, width int) string { func (m Model) renderSchedule() string { if m.scheduleLoading { - return "\n" + styleMuted.Render(" Loading schedule…") + return "\n" + m.styles.Muted.Render(" Loading schedule…") } var onCallLine string if m.currentOnCall != nil { onCallLine = fmt.Sprintf(" On-call today: %s", - styleAlertName.Render(m.currentOnCall.Username)) + m.styles.AlertName.Render(m.currentOnCall.Username)) } else { - onCallLine = styleMuted.Render(" On-call today: nobody scheduled") + onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled") } from := m.scheduleWindow to := m.scheduleWindow.AddDate(0, 0, 6) - windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s", + windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s", from.Format("Jan 02"), to.Format("Jan 02, 2006"))) header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n" @@ -346,12 +346,12 @@ func (m Model) renderSchedule() string { func (m Model) renderUserPicker() string { if m.usersLoading { - return "\n" + styleMuted.Render(" Loading users…") + return "\n" + m.styles.Muted.Render(" Loading users…") } if m.pickerTarget == pickerIncidentAssignee { header := fmt.Sprintf("\n Assign %s to:\n\n", - styleBold.Render(m.selectedIncident.Title)) + m.styles.Bold.Render(m.selectedIncident.Title)) return header + m.userPickerTable.View() } @@ -376,7 +376,7 @@ func (m Model) renderUserPicker() string { } } - header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope)) + header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope)) return header + m.userPickerTable.View() } @@ -384,14 +384,14 @@ func (m Model) renderUserPicker() string { func (m Model) renderDetail() string { if m.detailLoading { - return "\n" + styleMuted.Render(" Loading…") + return "\n" + m.styles.Muted.Render(" Loading…") } return m.detailViewport.View() } // renderPrompt puts an input line under the detail pane. func (m Model) renderPrompt(prompt string) string { - sep := styleMuted.Render(strings.Repeat("─", m.width)) + sep := m.styles.Muted.Render(strings.Repeat("─", m.width)) return m.detailViewport.View() + "\n" + sep + "\n" + prompt } @@ -401,7 +401,7 @@ func (m Model) renderStats() string { // Only announce loading before the first result: a background refresh must not // blank the page out from under whoever is reading it. if m.statsLoading && !m.statsLoaded { - return "\n" + styleMuted.Render(" Loading statistics…") + return "\n" + m.styles.Muted.Render(" Loading statistics…") } return m.statsViewport.View() } @@ -418,16 +418,16 @@ func line(style lipgloss.Style, s string) string { // ── Content builders ─────────────────────────────────────────────────────── -func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { +func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string { now := time.Now() var b strings.Builder contentW := width - 4 // Title + status header - title := styleAlertName.Render(inc.Title) - status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc)) + title := s.AlertName.Render(inc.Title) + status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc)) if inc.Severity != "" { - status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity)) + status += " " + s.Severity(inc.Severity).Render(strings.ToUpper(inc.Severity)) } gap := contentW - lipgloss.Width(title) - lipgloss.Width(status) if gap < 1 { @@ -440,9 +440,9 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt))) if inc.AssignedTo != "" { - b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo))) + b.WriteString(fmt.Sprintf(" Assigned: %s\n", s.Bold.Render(inc.AssignedTo))) } else { - b.WriteString(line(styleMuted, " Assigned: nobody")) + b.WriteString(line(s.Muted, " Assigned: nobody")) } if inc.AcknowledgedByID != nil { @@ -450,14 +450,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, if inc.AcknowledgedAt != nil { ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC") } - b.WriteString(line(styleResolved, + b.WriteString(line(s.Resolved, fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt))) } else { - b.WriteString(line(styleMuted, " Acked: not acknowledged")) + b.WriteString(line(s.Muted, " Acked: not acknowledged")) } if inc.IsSnoozed() { - b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)", + b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)", inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil)))) } @@ -470,14 +470,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source)) } if inc.ArchivedAt != nil { - b.WriteString(line(styleMuted, " Archived: "+ + b.WriteString(line(s.Muted, " Archived: "+ inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC"))) } b.WriteString("\n") // Group labels — the correlation Alertmanager applied. if len(inc.GroupLabels) > 0 { - b.WriteString(divider("Grouped By", width)) + b.WriteString(divider(s, "Grouped By", width)) for _, k := range sortedKeys(inc.GroupLabels) { b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24))) } @@ -485,14 +485,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, } // Member alerts - b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) + b.WriteString(divider(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width)) if len(inc.Alerts) == 0 { - b.WriteString(line(styleMuted, " No alerts.")) + b.WriteString(line(s.Muted, " No alerts.")) } else { for _, a := range inc.Alerts { - marker := styleFiring.Render("●") + marker := s.Firing.Render("●") if a.Status != "firing" { - marker = styleResolved.Render("✓") + marker = s.Resolved.Render("✓") } instance := a.Labels["instance"] if instance == "" { @@ -500,29 +500,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, } b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n", marker, truncate(a.Name, 28), truncate(instance, 26), - styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) + s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt)))) } } b.WriteString("\n") // Timeline — the only history the server keeps. notes := noteEvents(timeline) - b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) + b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width)) if len(timeline) == 0 { - b.WriteString(line(styleMuted, " Nothing recorded yet.")) + b.WriteString(line(s.Muted, " Nothing recorded yet.")) } else { noteIndex := 0 for _, e := range timeline { - when := styleMuted.Render(humanAgo(now, e.CreatedAt)) + when := s.Muted.Render(humanAgo(now, e.CreatedAt)) if e.Type != api.EventNote { b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when)) continue } marker := " " - author := styleBold.Render(e.Username) + author := s.Bold.Render(e.Username) if noteIndex == cursor { - marker = styleSelected.Render("> ") - author = styleSelected.Render(e.Username) + marker = s.Selected.Render("> ") + author = s.Selected.Render(e.Username) } b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when)) b.WriteString(" " + e.Detail + "\n") @@ -626,21 +626,21 @@ func notifyKind(detail string) string { return " (" + detail + ")" } -func buildAlertDetailContent(alert api.Alert, width int) string { +func buildAlertDetailContent(s Styles, alert api.Alert, width int) string { now := time.Now() var b strings.Builder contentW := width - 4 - name := styleAlertName.Render(alert.Name) + name := s.AlertName.Render(alert.Name) var statusStr string if alert.Status == "firing" { - statusStr = styleFiring.Render("● FIRING") + statusStr = s.Firing.Render("● FIRING") } else { label := "✓ RESOLVED" if alert.ResolutionSource != nil { label += " · " + *alert.ResolutionSource } - statusStr = styleResolved.Render(label) + statusStr = s.Resolved.Render(label) } gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr) if gap < 1 { @@ -660,15 +660,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string { } if alert.IncidentID != nil { b.WriteString(fmt.Sprintf(" Incident: %s %s\n", - styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), - styleMuted.Render("press i to open it"))) + s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)), + s.Muted.Render("press i to open it"))) } else { - b.WriteString(line(styleMuted, " Incident: none")) + b.WriteString(line(s.Muted, " Incident: none")) } b.WriteString("\n") if len(alert.Labels) > 0 { - b.WriteString(divider("Labels", width)) + b.WriteString(divider(s, "Labels", width)) for _, k := range sortedKeys(alert.Labels) { b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24))) } @@ -676,7 +676,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string { } if len(alert.Annotations) > 0 { - b.WriteString(divider("Annotations", width)) + b.WriteString(divider(s, "Annotations", width)) for _, k := range sortedKeys(alert.Annotations) { b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24))) } @@ -684,14 +684,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string { } // Alerts carry no workflow state: it all lives on the incident. - b.WriteString(divider("", width)) - b.WriteString(line(styleMuted, + b.WriteString(divider(s, "", width)) + b.WriteString(line(s.Muted, " Alerts are read-only — acknowledge, assign, note and resolve on the incident.")) return b.String() } -func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { +func buildStatsContent(s Styles, incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string { barWidth := width/2 - 10 if barWidth < 8 { barWidth = 8 @@ -704,41 +704,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour b.WriteString("\n") // Response times first: they are what a rota is actually judged on. - b.WriteString(divider("Incident Response", width)) + b.WriteString(divider(s, "Incident Response", width)) if incidents == nil { - b.WriteString(line(styleMuted, " No data.")) + b.WriteString(line(s.Muted, " No data.")) } else { b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total", - styleBold.Render(fmt.Sprintf("%d", incidents.Total)))) + s.Bold.Render(fmt.Sprintf("%d", incidents.Total)))) b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered", - styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) + s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered)))) b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged", - styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) + s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged)))) b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved", - styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) + s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved)))) b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge", - styleBold.Render(humanSeconds(incidents.MTTASeconds)))) + s.Bold.Render(humanSeconds(incidents.MTTASeconds)))) b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve", - styleBold.Render(humanSeconds(incidents.MTTRSeconds)))) + s.Bold.Render(humanSeconds(incidents.MTTRSeconds)))) if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil { - b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)")) + b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)")) } } b.WriteString("\n") - b.WriteString(divider("Top Alerts", width)) + b.WriteString(divider(s, "Top Alerts", width)) if len(top) == 0 { - b.WriteString(line(styleMuted, " No data.")) + b.WriteString(line(s.Muted, " No data.")) } else { maxCount := top[0].Count for i, a := range top { - bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth))) + bar := s.Resolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth))) b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count)) } } b.WriteString("\n") - b.WriteString(divider("Alerts by Hour (UTC)", width)) + b.WriteString(divider(s, "Alerts by Hour (UTC)", width)) if len(byHour) > 0 { maxCount := 0 for _, h := range byHour { @@ -747,15 +747,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour } } for _, h := range byHour { - bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth))) + bar := s.Firing.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth))) b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count)) } } else { - b.WriteString(line(styleMuted, " No data.")) + b.WriteString(line(s.Muted, " No data.")) } b.WriteString("\n") - b.WriteString(divider("Alerts by Day", width)) + b.WriteString(divider(s, "Alerts by Day", width)) if len(byDay) > 0 { maxCount := 0 for _, d := range byDay { @@ -764,11 +764,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour } } for _, d := range byDay { - bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth))) + bar := s.Accent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth))) b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count)) } } else { - b.WriteString(line(styleMuted, " No data.")) + b.WriteString(line(s.Muted, " No data.")) } return b.String() @@ -778,22 +778,22 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour func (m Model) renderUsers() string { if m.usersLoading { - return "\n" + styleMuted.Render(" Loading users…") + return "\n" + m.styles.Muted.Render(" Loading users…") } if len(m.users) == 0 { - return "\n" + styleMuted.Render(" No users found. Press n to create one.") + return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.") } return "\n" + m.userManageTable.View() } func (m Model) renderUserCreate() string { - header := "\n " + styleBold.Render("Create new user") + "\n\n" + header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n" usernameLabel := " Username: " emailLabel := " Email: " if m.userFormFocus == 0 { - usernameLabel = styleSelected.Render(" Username: ") + usernameLabel = m.styles.Selected.Render(" Username: ") } else { - emailLabel = styleSelected.Render(" Email: ") + emailLabel = m.styles.Selected.Render(" Email: ") } return header + usernameLabel + m.userFormInputs[0].View() + "\n" + @@ -801,59 +801,59 @@ func (m Model) renderUserCreate() string { } func (m Model) renderUserNotifyEdit() string { - header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username)) - hint := line(styleMuted, + header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) + hint := line(m.styles.Muted, " The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+ " their incidents then page the server's shared fallback topic, which\n"+ " carries no Acknowledge button.") - label := styleSelected.Render(" Topic: ") + label := m.styles.Selected.Render(" Topic: ") return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n" } func (m Model) renderAPIKeyMenu() string { - header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username)) - warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") + header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) + warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.") options := "\n" + - styleAccent.Render(" n") + " · create a new API key\n" + - styleAccent.Render(" r") + " · revoke a key by ID\n" + m.styles.Accent.Render(" n") + " · create a new API key\n" + + m.styles.Accent.Render(" r") + " · revoke a key by ID\n" return header + "\n" + warning + options } func (m Model) renderAPIKeyCreate() string { - header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username)) - label := styleSelected.Render(" Key name: ") + header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username)) + label := m.styles.Selected.Render(" Key name: ") return header + label + m.apiKeyNameInput.View() + "\n" } func (m Model) renderAPIKeyReveal() string { - sep := styleMuted.Render(strings.Repeat("─", m.width)) - warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!") - nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name)) + sep := m.styles.Muted.Render(strings.Repeat("─", m.width)) + warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!") + nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name)) idLine := fmt.Sprintf(" Key ID: %s %s", - styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), - styleMuted.Render("(save this — needed for future revocation)")) + m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)), + m.styles.Muted.Render("(save this — needed for future revocation)")) - keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key) + keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key) return "\n" + sep + "\n\n" + warn + "\n\n" + nameLine + "\n" + idLine + "\n\n" + - styleMuted.Render(" Key value:") + "\n" + + m.styles.Muted.Render(" Key value:") + "\n" + keyLine + "\n\n" + sep + "\n" } func (m Model) renderAPIKeyRevokeByID() string { - header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username)) - hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).") - label := styleSelected.Render(" Key ID: ") + header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username)) + hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).") + label := m.styles.Selected.Render(" Key ID: ") return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n" } // ── Helpers ──────────────────────────────────────────────────────────────── -func divider(title string, width int) string { +func divider(s Styles, title string, width int) string { prefix := "── " if title != "" { prefix += title + " " @@ -862,7 +862,7 @@ func divider(title string, width int) string { if remaining > 0 { prefix += strings.Repeat("─", remaining) } - return styleMuted.Render(prefix) + "\n" + return s.Muted.Render(prefix) + "\n" } func sortedKeys(m map[string]string) []string { diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index ff74968..78788a4 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -7,6 +7,7 @@ import ( "time" "git.ryuvia.com/niklas/terdut-tui/internal/api" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" tea "github.com/charmbracelet/bubbletea" ) @@ -16,6 +17,10 @@ var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`) func plain(s string) string { return ansi.ReplaceAllString(s, "") } +// testStyles is the default theme, so assertions here run against what a user +// with no 'theme:' key actually sees. +func testStyles() Styles { return newStyles(theme.GruvboxDark) } + func mustContain(t *testing.T, got string, wants ...string) { t.Helper() got = plain(got) @@ -52,7 +57,7 @@ func TestIncidentDetail_RendersTheWholeStory(t *testing.T) { {Type: api.EventNote, Username: "admin", Detail: "draining node-2", CreatedAt: now}, } - out := buildIncidentDetailContent(inc, timeline, -1, 110) + out := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 110) mustContain(t, out, "DiskFull (namespace=prod)", "ACKNOWLEDGED", "CRITICAL", "Assigned:", "admin", @@ -72,7 +77,7 @@ func TestIncidentDetail_ShowsSnooze(t *testing.T) { } // The exact remaining time is humanUntil's business, not this test's — a few // microseconds of elapsed clock turn "in 2h" into "in 1h 59m". - mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), + mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "TRIGGERED (snoozed)", "Snoozed:", "until", "in 1h") } @@ -83,7 +88,7 @@ func TestIncidentDetail_HidesExpiredSnooze(t *testing.T) { Title: "Noisy", Status: api.StatusTriggered, TriggeredAt: time.Now(), SnoozedUntil: &past, } - if strings.Contains(plain(buildIncidentDetailContent(inc, nil, -1, 110)), "Snoozed:") { + if strings.Contains(plain(buildIncidentDetailContent(testStyles(), inc, nil, -1, 110)), "Snoozed:") { t.Error("an expired snooze should not be rendered") } } @@ -95,18 +100,18 @@ func TestIncidentDetail_ShowsResolutionSource(t *testing.T) { Title: "Done", Status: api.StatusResolved, TriggeredAt: now.Add(-time.Hour), ResolvedAt: &now, ResolutionSource: &source, } - mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), + mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "RESOLVED", "Resolved:", "manual") } func TestIncidentDetail_UnassignedAndUnacknowledged(t *testing.T) { inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} - mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "nobody", "not acknowledged") + mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "nobody", "not acknowledged") } func TestIncidentDetail_EmptyTimeline(t *testing.T) { inc := api.Incident{Title: "Fresh", Status: api.StatusTriggered, TriggeredAt: time.Now()} - mustContain(t, buildIncidentDetailContent(inc, nil, -1, 110), "Nothing recorded yet") + mustContain(t, buildIncidentDetailContent(testStyles(), inc, nil, -1, 110), "Nothing recorded yet") } func TestIncidentDetail_MarksSelectedNote(t *testing.T) { @@ -117,7 +122,7 @@ func TestIncidentDetail_MarksSelectedNote(t *testing.T) { } inc := api.Incident{Title: "X", Status: api.StatusTriggered, TriggeredAt: now} - out := plain(buildIncidentDetailContent(inc, timeline, 1, 110)) + out := plain(buildIncidentDetailContent(testStyles(), inc, timeline, 1, 110)) for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "alice") && !strings.HasPrefix(line, "> ") { t.Errorf("expected the selected note marked, got %q", line) @@ -207,7 +212,7 @@ func TestIncidentDetail_RendersNotifications(t *testing.T) { Detail: "reminder: ntfy returned 502", CreatedAt: now}, } - got := buildIncidentDetailContent(inc, timeline, -1, 120) + got := buildIncidentDetailContent(testStyles(), inc, timeline, -1, 120) mustContain(t, got, "Notified niklas (triggered)", "Notification to niklas failed") } @@ -230,7 +235,7 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { Labels: map[string]string{"instance": "node-1", "severity": "critical"}, Annotations: map[string]string{"summary": "disk 90%"}, } - mustContain(t, buildAlertDetailContent(alert, 110), + mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "DiskFull", "FIRING", "Incident:", "#7", "press i to open it", "instance", "node-1", "summary", "disk 90%", "Alerts are read-only") @@ -238,21 +243,21 @@ func TestAlertDetail_SaysItIsReadOnlyAndLinksTheIncident(t *testing.T) { func TestAlertDetail_NoIncident(t *testing.T) { alert := api.Alert{ID: 3, Name: "Orphan", Status: "resolved", ReceivedAt: time.Now()} - mustContain(t, buildAlertDetailContent(alert, 110), "Incident:", "none") + mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "Incident:", "none") } func TestAlertDetail_ShowsResolutionSource(t *testing.T) { source := "expiry" alert := api.Alert{Name: "Gone", Status: "resolved", ReceivedAt: time.Now(), ResolutionSource: &source} - mustContain(t, buildAlertDetailContent(alert, 110), "RESOLVED", "expiry") + mustContain(t, buildAlertDetailContent(testStyles(), alert, 110), "RESOLVED", "expiry") } // Null MTTA means nothing has been acknowledged, which is a different claim // from an instant response. func TestStats_RendersDashForMissingAverages(t *testing.T) { stats := &api.IncidentStats{Total: 2, Triggered: 2} - out := buildStatsContent(stats, nil, nil, nil, 110) + out := buildStatsContent(testStyles(), stats, nil, nil, nil, 110) mustContain(t, out, "Incident Response", "Mean time to acknowledge", "—", "nothing has been acknowledged or resolved yet") } @@ -260,12 +265,12 @@ func TestStats_RendersDashForMissingAverages(t *testing.T) { func TestStats_RendersAverages(t *testing.T) { mtta, mttr := 150.0, 3600.0 stats := &api.IncidentStats{Total: 3, Resolved: 1, MTTASeconds: &mtta, MTTRSeconds: &mttr} - out := buildStatsContent(stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) + out := buildStatsContent(testStyles(), stats, []api.TopAlert{{Name: "DiskFull", Count: 4}}, nil, nil, 110) mustContain(t, out, "2m", "1h", "Top Alerts", "DiskFull") } func TestStats_HandlesNoIncidentData(t *testing.T) { - mustContain(t, buildStatsContent(nil, nil, nil, nil, 110), "Incident Response", "No data") + mustContain(t, buildStatsContent(testStyles(), nil, nil, nil, nil, 110), "Incident Response", "No data") } func TestView_TabsAndDashboardRender(t *testing.T) { @@ -296,7 +301,7 @@ func TestView_EmptyStates(t *testing.T) { // The stats page renders inside the normal section chrome now, so it has to // survive the real path: a window size message sizes the viewport and fills it. func TestView_StatsSectionRendersInPlace(t *testing.T) { - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) m.connected = true m.incidentStats = &api.IncidentStats{Total: 3, Triggered: 1} m.topAlerts = []api.TopAlert{{Name: "DiskFull", Count: 4}} @@ -335,7 +340,7 @@ func TestFooter_IncidentDetailOffersResolveOnlyWhileOpen(t *testing.T) { } func TestView_ZeroWidthRendersNothing(t *testing.T) { - m := NewModel(nil, "http://test", time.Minute) + m := NewModel(nil, "http://test", time.Minute, theme.GruvboxDark) if m.View() != "" { t.Error("expected no output before the first window size message") } diff --git a/main.go b/main.go index 7adab91..d618797 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "git.ryuvia.com/niklas/terdut-tui/internal/api" "git.ryuvia.com/niklas/terdut-tui/internal/config" + "git.ryuvia.com/niklas/terdut-tui/internal/theme" "git.ryuvia.com/niklas/terdut-tui/internal/tui" "git.ryuvia.com/niklas/terdut-tui/internal/updater" tea "github.com/charmbracelet/bubbletea" @@ -38,8 +39,14 @@ func main() { os.Exit(1) } + th, err := theme.Load(cfg.Theme) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + client := api.NewClient(cfg.ServerURL, cfg.APIKey) - model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval) + model := tui.NewModel(client, cfg.ServerURL, cfg.RefreshInterval, th) p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { -- 2.52.0