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
+3 -2
View File
@@ -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)