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:
+21
-16
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user