Colour themes, defaulting to gruvbox dark
CI / test (pull_request) Successful in 4s

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:
Niklas Ye
2026-08-20 11:06:36 +02:00
parent dc53d49c3e
commit 4a579bdbc6
15 changed files with 996 additions and 226 deletions
+142 -66
View File
@@ -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
}