From e53077fe7094d2706a515205fdcf9823f7667a1f Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Thu, 4 Jun 2026 19:37:20 -0400 Subject: [PATCH] Revert "feat(tui): overhaul latency sparkline scaling, color, and layout" This reverts commit 63926e2932ba11afe96565b711696680b5dd12bf. --- internal/tui/braille.go | 103 --------------------------------- internal/tui/braille_test.go | 64 -------------------- internal/tui/sparkline.go | 87 ++++++++-------------------- internal/tui/sparkline_test.go | 94 ++---------------------------- internal/tui/tab_sites.go | 19 +----- internal/tui/tui.go | 9 --- internal/tui/view_detail.go | 4 +- internal/tui/view_history.go | 4 +- 8 files changed, 35 insertions(+), 349 deletions(-) delete mode 100644 internal/tui/braille.go delete mode 100644 internal/tui/braille_test.go diff --git a/internal/tui/braille.go b/internal/tui/braille.go deleted file mode 100644 index 6088d58..0000000 --- a/internal/tui/braille.go +++ /dev/null @@ -1,103 +0,0 @@ -package tui - -// braillePlane is a subpixel canvas where each terminal cell maps to a 2×4 -// dot grid, rendered via Unicode braille (U+2800..U+28FF). -type braillePlane struct { - wCells, hCells int - wDots, hDots int - dots []bool -} - -func newBraillePlane(wCells, hCells int) *braillePlane { - wd, hd := wCells*2, hCells*4 - return &braillePlane{ - wCells: wCells, hCells: hCells, - wDots: wd, hDots: hd, - dots: make([]bool, wd*hd), - } -} - -func (p *braillePlane) set(dx, dy int) { - if dx < 0 || dy < 0 || dx >= p.wDots || dy >= p.hDots { - return - } - p.dots[dy*p.wDots+dx] = true -} - -// line draws a Bresenham line between two dot coordinates. -func (p *braillePlane) line(x0, y0, x1, y1 int) { - dx := intAbs(x1 - x0) - sx := 1 - if x0 >= x1 { - sx = -1 - } - dy := -intAbs(y1 - y0) - sy := 1 - if y0 >= y1 { - sy = -1 - } - err := dx + dy - for { - p.set(x0, y0) - if x0 == x1 && y0 == y1 { - return - } - e2 := 2 * err - if e2 >= dy { - err += dy - x0 += sx - } - if e2 <= dx { - err += dx - y0 += sy - } - } -} - -// fillBelow fills all dots below the topmost lit dot in each column, -// producing an area-chart effect. -func (p *braillePlane) fillBelow() { - for x := 0; x < p.wDots; x++ { - topY := -1 - for y := 0; y < p.hDots; y++ { - if p.dots[y*p.wDots+x] { - topY = y - break - } - } - if topY >= 0 { - for y := topY + 1; y < p.hDots; y++ { - p.dots[y*p.wDots+x] = true - } - } - } -} - -// cellMask builds the U+2800-relative bitmask for one terminal cell. -func (p *braillePlane) cellMask(cx, cy int) byte { - type bit struct { - dx, dy int - m byte - } - bits := [...]bit{ - {0, 0, 0x01}, {0, 1, 0x02}, {0, 2, 0x04}, - {1, 0, 0x08}, {1, 1, 0x10}, {1, 2, 0x20}, - {0, 3, 0x40}, {1, 3, 0x80}, - } - var mask byte - for _, b := range bits { - dx := cx*2 + b.dx - dy := cy*4 + b.dy - if dx >= 0 && dx < p.wDots && dy >= 0 && dy < p.hDots && p.dots[dy*p.wDots+dx] { - mask |= b.m - } - } - return mask -} - -func intAbs(n int) int { - if n < 0 { - return -n - } - return n -} diff --git a/internal/tui/braille_test.go b/internal/tui/braille_test.go deleted file mode 100644 index 3508e75..0000000 --- a/internal/tui/braille_test.go +++ /dev/null @@ -1,64 +0,0 @@ -package tui - -import "testing" - -func TestBraillePlane_Set(t *testing.T) { - p := newBraillePlane(2, 1) - if p.wDots != 4 || p.hDots != 4 { - t.Fatalf("expected 4x4 dots, got %dx%d", p.wDots, p.hDots) - } - p.set(0, 0) - if !p.dots[0] { - t.Error("dot at (0,0) should be set") - } - p.set(-1, 0) // out of bounds, should not panic - p.set(0, 99) // out of bounds, should not panic -} - -func TestBraillePlane_CellMask(t *testing.T) { - p := newBraillePlane(1, 1) - // Set bottom-left dot - p.set(0, 3) - mask := p.cellMask(0, 0) - if mask != 0x40 { - t.Errorf("bottom-left dot should be 0x40, got 0x%02x", mask) - } - // Set all dots - for y := 0; y < 4; y++ { - for x := 0; x < 2; x++ { - p.set(x, y) - } - } - mask = p.cellMask(0, 0) - if mask != 0xFF { - t.Errorf("all dots should be 0xFF, got 0x%02x", mask) - } -} - -func TestBraillePlane_Line(t *testing.T) { - p := newBraillePlane(3, 1) - p.line(0, 2, 5, 2) // horizontal line - for x := 0; x <= 5; x++ { - if !p.dots[2*p.wDots+x] { - t.Errorf("dot at (%d, 2) should be set", x) - } - } -} - -func TestBraillePlane_FillBelow(t *testing.T) { - p := newBraillePlane(1, 1) - p.set(0, 1) // set dot at row 1 - p.fillBelow() - if !p.dots[1*p.wDots+0] { - t.Error("original dot should still be set") - } - if !p.dots[2*p.wDots+0] { - t.Error("row 2 should be filled") - } - if !p.dots[3*p.wDots+0] { - t.Error("row 3 should be filled") - } - if p.dots[0*p.wDots+0] { - t.Error("row 0 above the dot should not be filled") - } -} diff --git a/internal/tui/sparkline.go b/internal/tui/sparkline.go index 9f0521b..f659fda 100644 --- a/internal/tui/sparkline.go +++ b/internal/tui/sparkline.go @@ -1,63 +1,15 @@ package tui import ( - "fmt" "strings" "time" - - "github.com/charmbracelet/lipgloss" ) var sparkChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} -func parseHex(hex string) (r, g, b uint8) { - if len(hex) == 7 && hex[0] == '#' { - fmt.Sscanf(hex[1:], "%02x%02x%02x", &r, &g, &b) - } - return -} - -func dimColor(hex string, brightness float64) lipgloss.Color { - r, g, b := parseHex(hex) - f := 0.3 + brightness*0.7 - return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", - uint8(float64(r)*f), - uint8(float64(g)*f), - uint8(float64(b)*f), - )) -} - -func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style { - if bg != "" { - return s.Background(bg) - } - return s -} - -func latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style { - var hex string - var t float64 - switch { - case ms < 200: - hex = sparkSuccess - t = float64(ms) / 200 - case ms < 500: - hex = sparkWarning - t = float64(ms-200) / 300 - default: - hex = sparkDanger - t = float64(ms-500) / 1500 - if t > 1 { - t = 1 - } - } - s := lipgloss.NewStyle().Foreground(dimColor(hex, t)) - return withBg(s, bg) -} - -func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string { +func latencySparkline(latencies []time.Duration, statuses []bool, width int) string { if len(latencies) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return subtleStyle.Render(strings.Repeat("·", width)) } samples := latencies @@ -78,12 +30,12 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg maxL = l } } - spread := maxL - minL var sb strings.Builder if remaining := width - len(samples); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) } + spread := maxL - minL for i, l := range samples { idx := 0 if spread > 0 { @@ -95,17 +47,24 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int, bg ch := string(sparkChars[idx]) isDown := i < len(sampledStatuses) && !sampledStatuses[i] if isDown { - sb.WriteString(withBg(dangerStyle, bg).Render(ch)) + sb.WriteString(dangerStyle.Render(ch)) } else { - sb.WriteString(latencyStyle(l.Milliseconds(), bg).Render(ch)) + ms := l.Milliseconds() + if ms < 200 { + sb.WriteString(specialStyle.Render(ch)) + } else if ms < 500 { + sb.WriteString(warnStyle.Render(ch)) + } else { + sb.WriteString(dangerStyle.Render(ch)) + } } } return sb.String() } -func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { +func heartbeatSparkline(statuses []bool, width int) string { if len(statuses) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return subtleStyle.Render(strings.Repeat("·", width)) } samples := statuses @@ -115,19 +74,19 @@ func heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string { var sb strings.Builder if remaining := width - len(samples); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) } for _, up := range samples { if up { - sb.WriteString(withBg(specialStyle, bg).Render("▁")) + sb.WriteString(specialStyle.Render("▁")) } else { - sb.WriteString(withBg(dangerStyle, bg).Render("█")) + sb.WriteString(dangerStyle.Render("█")) } } return sb.String() } -func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string { +func (m Model) groupSparkline(groupID int, width int) string { allSites := m.engine.GetAllSites() var childStatuses [][]bool for _, s := range allSites { @@ -140,7 +99,7 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string } if len(childStatuses) == 0 { - return withBg(subtleStyle, bg).Render(strings.Repeat("·", width)) + return subtleStyle.Render(strings.Repeat("·", width)) } maxLen := 0 @@ -168,13 +127,13 @@ func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string var sb strings.Builder if remaining := width - len(aggregated); remaining > 0 { - sb.WriteString(withBg(subtleStyle, bg).Render(strings.Repeat("·", remaining))) + sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining))) } for _, up := range aggregated { if up { - sb.WriteString(withBg(subtleStyle, bg).Render("·")) + sb.WriteString(specialStyle.Render("•")) } else { - sb.WriteString(withBg(dangerStyle, bg).Render("•")) + sb.WriteString(dangerStyle.Render("•")) } } return sb.String() diff --git a/internal/tui/sparkline_test.go b/internal/tui/sparkline_test.go index 0861aab..6a1d57d 100644 --- a/internal/tui/sparkline_test.go +++ b/internal/tui/sparkline_test.go @@ -4,11 +4,10 @@ import ( "strings" "testing" "time" - "unicode/utf8" ) func TestLatencySparkline_Empty(t *testing.T) { - got := latencySparkline(nil, nil, 10, "") + got := latencySparkline(nil, nil, 10) if !strings.Contains(got, "··········") { t.Errorf("empty sparkline should be dots, got %q", got) } @@ -17,13 +16,10 @@ func TestLatencySparkline_Empty(t *testing.T) { func TestLatencySparkline_SingleValue(t *testing.T) { latencies := []time.Duration{100 * time.Millisecond} statuses := []bool{true} - got := latencySparkline(latencies, statuses, 5, "") + got := latencySparkline(latencies, statuses, 5) if len(got) == 0 { t.Error("sparkline should not be empty") } - if !strings.Contains(got, "····") { - t.Errorf("single value with width=5 should have 4 dot padding, got %q", got) - } } func TestLatencySparkline_WidthTruncation(t *testing.T) { @@ -33,92 +29,14 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) { latencies[i] = time.Duration(i*50) * time.Millisecond statuses[i] = true } - got := latencySparkline(latencies, statuses, 5, "") + got := latencySparkline(latencies, statuses, 5) if len(got) == 0 { t.Error("sparkline should not be empty") } - if strings.Contains(got, "·") { - t.Errorf("20 samples in width=5 should have no padding, got %q", got) - } -} - -func TestLatencySparkline_RelativeHeight(t *testing.T) { - latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond} - statuses := []bool{true, true, true} - out := stripANSI(latencySparkline(latencies, statuses, 3, "")) - runes := []rune(out) - if len(runes) < 3 { - t.Fatalf("expected 3 runes, got %d", len(runes)) - } - if runes[0] == runes[1] { - t.Errorf("min and max should have different bar heights, got %c %c %c", runes[0], runes[1], runes[2]) - } -} - -func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) { - sparkSuccess = "#00ff00" - sparkWarning = "#ffff00" - sparkDanger = "#ff0000" - defer func() { - sparkSuccess = "" - sparkWarning = "" - sparkDanger = "" - }() - - green := latencyStyle(50, "") - yellow := latencyStyle(300, "") - red := latencyStyle(800, "") - - gfg := green.GetForeground() - yfg := yellow.GetForeground() - rfg := red.GetForeground() - - if gfg == yfg || yfg == rfg || gfg == rfg { - t.Errorf("bands should produce distinct foreground colors: green=%v yellow=%v red=%v", gfg, yfg, rfg) - } -} - -func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) { - sparkSuccess = "#00ff00" - defer func() { sparkSuccess = "" }() - - dim := latencyStyle(10, "") - bright := latencyStyle(190, "") - - if dim.GetForeground() == bright.GetForeground() { - t.Error("10ms and 190ms should have different brightness within green band") - } -} - -func TestLatencySparkline_OutputWidth(t *testing.T) { - latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond} - statuses := []bool{true, true, true} - got := latencySparkline(latencies, statuses, 5, "") - count := utf8.RuneCountInString(stripANSI(got)) - if count != 5 { - t.Errorf("expected 5 rune-width output, got %d from %q", count, got) - } -} - -func stripANSI(s string) string { - var out strings.Builder - i := 0 - for i < len(s) { - if s[i] == '\x1b' { - for i < len(s) && s[i] != 'm' { - i++ - } - i++ - continue - } - out.WriteByte(s[i]) - i++ - } - return out.String() } func TestHeartbeatSparkline_Empty(t *testing.T) { - got := heartbeatSparkline(nil, 10, "") + got := heartbeatSparkline(nil, 10) if !strings.Contains(got, "··········") { t.Errorf("empty heartbeat should be dots, got %q", got) } @@ -126,7 +44,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) { func TestHeartbeatSparkline_Mixed(t *testing.T) { statuses := []bool{true, false, true, true, false} - got := heartbeatSparkline(statuses, 5, "") + got := heartbeatSparkline(statuses, 5) if len(got) == 0 { t.Error("heartbeat sparkline should not be empty") } @@ -134,7 +52,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) { func TestHeartbeatSparkline_PaddedWidth(t *testing.T) { statuses := []bool{true, true} - got := heartbeatSparkline(statuses, 5, "") + got := heartbeatSparkline(statuses, 5) if !strings.Contains(got, "···") { t.Errorf("should have dot padding for width > data, got %q", got) } diff --git a/internal/tui/tab_sites.go b/internal/tui/tab_sites.go index ba828da..0e4bb46 100644 --- a/internal/tui/tab_sites.go +++ b/internal/tui/tab_sites.go @@ -86,9 +86,6 @@ func (m Model) computeLayout() tableLayout { if sparkW < 15 { sparkW = 15 } - if sparkW > 62 { - sparkW = 62 - } widths[1] = nameW widths[6] = sparkW @@ -113,9 +110,6 @@ func (m Model) viewSitesTab() string { if sparkWidth < 8 { sparkWidth = 8 } - if sparkWidth > 60 { - sparkWidth = 60 - } var groupRows map[int]bool return m.renderTable( @@ -126,13 +120,6 @@ func (m Model) viewSitesTab() string { var rows [][]string for i := start; i < end; i++ { site := m.sites[i] - rowIdx := i - start - var rowBg lipgloss.Color - if i == m.cursor { - rowBg = m.theme.SelectedBg - } else if rowIdx%2 == 1 { - rowBg = m.theme.ZebraBg - } if site.Type == "group" { groupRows[i-start] = true @@ -144,7 +131,7 @@ func (m Model) viewSitesTab() string { fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)), subtleStyle.Render("—"), m.groupUptime(site.ID), - m.groupSparkline(site.ID, sparkWidth, rowBg), + m.groupSparkline(site.ID, sparkWidth), subtleStyle.Render("-"), subtleStyle.Render("—"), }) @@ -179,9 +166,9 @@ func (m Model) viewSitesTab() string { hist, _ := m.engine.GetHistory(site.ID) var spark string if site.Type == "push" { - spark = heartbeatSparkline(hist.Statuses, sparkWidth, rowBg) + spark = heartbeatSparkline(hist.Statuses, sparkWidth) } else { - spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, rowBg) + spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth) } rows = append(rows, []string{ diff --git a/internal/tui/tui.go b/internal/tui/tui.go index bc0b3af..d08a945 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -25,10 +25,6 @@ var ( titleStyle lipgloss.Style activeTab lipgloss.Style inactiveTab lipgloss.Style - - sparkSuccess string - sparkWarning string - sparkDanger string ) func applyTheme(t Theme) { @@ -37,11 +33,6 @@ func applyTheme(t Theme) { warnStyle = lipgloss.NewStyle().Foreground(t.Warning) staleStyle = lipgloss.NewStyle().Foreground(t.Stale) dangerStyle = lipgloss.NewStyle().Foreground(t.Danger) - - sparkSuccess = string(t.Success) - sparkWarning = string(t.Warning) - sparkDanger = string(t.Danger) - titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true) activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1) inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted) diff --git a/internal/tui/view_detail.go b/internal/tui/view_detail.go index 2ab41f9..19fc22a 100644 --- a/internal/tui/view_detail.go +++ b/internal/tui/view_detail.go @@ -203,7 +203,7 @@ func (m Model) viewDetailPanel() string { b.WriteString(m.divider() + "\n") const sparkWidth = 40 if site.Type == "push" { - b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth, "")) + b.WriteString(" " + heartbeatSparkline(hist.Statuses, sparkWidth)) if len(hist.Statuses) > 0 { up := 0 for _, s := range hist.Statuses { @@ -216,7 +216,7 @@ func (m Model) viewDetailPanel() string { up, len(hist.Statuses)) } } else { - b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth, "")) + b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)) var minL, maxL, total time.Duration count := 0 for i, l := range hist.Latencies { diff --git a/internal/tui/view_history.go b/internal/tui/view_history.go index ee18a9a..40072eb 100644 --- a/internal/tui/view_history.go +++ b/internal/tui/view_history.go @@ -47,8 +47,6 @@ func computeHistoryStats(changes []models.StateChange) historyStats { return s } -var stateChangeChars = []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'} - func stateChangeSparkline(changes []models.StateChange, width int) string { if len(changes) < 2 || width < 4 { return "" @@ -93,7 +91,7 @@ func stateChangeSparkline(changes []models.StateChange, width int) string { if idx > 7 { idx = 7 } - ch := string(stateChangeChars[idx]) + ch := string(sparkChars[idx]) switch { case v >= 3: sb.WriteString(dangerStyle.Render(ch))