feat(tui): overhaul latency sparkline scaling, color, and layout
CI / test (pull_request) Successful in 2m39s
CI / lint (pull_request) Failing after 56s
CI / vulncheck (pull_request) Successful in 51s

Replace misleading relative-only sparkline with dual-channel design:
bar height uses relative scaling (shows stability and anomalies),
color+brightness uses absolute thresholds (shows fast vs slow).

- Add brightness gradient within color bands (dim→bright as latency
  increases toward the next threshold)
- Pass row background through sparkline rendering so zebra stripes
  and selection highlights carry through ANSI sequences
- Cap sparkline width to 60 (matches maxHistoryLen) and column
  width to 62 to eliminate trailing dead space
- Quiet group sparkline: subtle dots for healthy, bold red for down
- Add braille subpixel canvas (ported from meridian) for future
  multi-row graph use
This commit is contained in:
2026-06-04 19:32:02 -04:00
parent 00fa381a7c
commit 986681ef8a
8 changed files with 349 additions and 35 deletions
+88 -6
View File
@@ -4,10 +4,11 @@ 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)
}
@@ -16,10 +17,13 @@ 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) {
@@ -29,14 +33,92 @@ 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)
}
@@ -44,7 +126,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")
}
@@ -52,7 +134,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)
}