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
+21 -19
View File
@@ -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{
&noteIn, &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 {