diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-05 21:50:58 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-05 21:50:58 +0200 |
| commit | a4298701546b09fccb15ce30db7c7e3f4070525c (patch) | |
| tree | b3433014284ccd354be48efb2ce125ccaf236d7e /internal/tui/dashboard | |
| parent | 2bd89ced830f97fd12a672fddb6978d204a014fd (diff) | |
fix(tui): stabilize full-width layout and sparkline rendering
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 9 | ||||
| -rw-r--r-- | internal/tui/dashboard/layout.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 38 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 31 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 85 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 82 |
6 files changed, 144 insertions, 105 deletions
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 7613230..3511dfb 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -14,9 +14,10 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Latency: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -27,9 +28,10 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Gaps: waiting for stats...") } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) - spark := common.PanelStyle.Width(panelInner).Render( + spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") @@ -53,6 +55,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he if width <= 0 { width = 80 } + panelW := panelWidth(width) panelInner := panelInnerWidth(width) if height > 0 { @@ -93,7 +96,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he } lines = append(lines, "Scale: █▓▒░") - return common.PanelStyle.Width(panelInner).Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n")) } func renderHistogramBar(count, maxCount uint64, width int) string { diff --git a/internal/tui/dashboard/layout.go b/internal/tui/dashboard/layout.go index 0035a9d..75cbafb 100644 --- a/internal/tui/dashboard/layout.go +++ b/internal/tui/dashboard/layout.go @@ -4,7 +4,3 @@ const panelHorizontalChrome = 4 // Keep a small guard so sparkline rows never soft-wrap in panel cells. const sparklineSafetyMargin = 3 - -// Stats engine currently provides 120 time-series slots; cap rendering width -// so wide terminals don't introduce wrap/placement artifacts. -const sparklineMaxWidth = 120 diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go index 3ddeaf6..866c5bb 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -33,6 +33,7 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { trendWithArrow(snap.ThroughputTrend), ) + panelW := panelWidth(width) panelInner := panelInnerWidth(width) labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") latencySpark := renderOverviewSparklineAligned("Latency:", snap.LatencySeriesNs(), panelInner, labelWidth) @@ -44,8 +45,8 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { latencyHist := "Latency buckets: " + summarizeHistogramBrief(snap.LatencyHistogram) gapHist := "Gap buckets: " + summarizeHistogramBrief(snap.GapHistogram) - panel := common.PanelStyle.Width(panelInner) - sparkPanel := panel.Render(strings.Join([]string{latencySpark, "", gapSpark, "", throughputSpark}, "\n")) + panel := common.PanelStyle.Width(panelW) + sparkPanel := panel.Render(strings.Join([]string{latencySpark, gapSpark, throughputSpark}, "\n")) topPanel := panel.Render(strings.Join([]string{topSyscalls, topFiles, topProcesses}, "\n")) histPanel := panel.Render(strings.Join([]string{latencyHist, gapHist}, "\n")) @@ -73,7 +74,7 @@ func renderSyscallBox(snap *statsengine.Snapshot, width int) string { snap.SyscallRatePerSec, generatedAt, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderBytesBox(snap *statsengine.Snapshot, width int) string { @@ -83,7 +84,7 @@ func renderBytesBox(snap *statsengine.Snapshot, width int) string { formatBytes(snap.WriteBytesPerSec), formatBytes(float64(snap.TotalBytes)), ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func renderErrorBox(snap *statsengine.Snapshot, width int) string { @@ -99,7 +100,7 @@ func renderErrorBox(snap *statsengine.Snapshot, width int) string { snap.LatencyMeanNs, snap.GapMeanNs, ) - return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) + return common.PanelStyle.Width(width).Height(5).Render(content) } func trendWithArrow(trend statsengine.Trend) string { @@ -212,19 +213,8 @@ func summaryBoxWidth(width int) int { return w } -func summaryBoxInnerWidth(width int) int { - inner := width - panelHorizontalChrome - if inner < 14 { - return 14 - } - return inner -} - func renderOverviewSparkline(label string, data []float64, panelInner int) string { w := panelInner - utf8.RuneCountInString(label) - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -234,9 +224,6 @@ func renderOverviewSparkline(label string, data []float64, panelInner int) strin func renderOverviewSparklineAligned(label string, data []float64, panelInner int, labelWidth int) string { paddedLabel := padLabelRight(label, labelWidth) w := panelInner - labelWidth - 1 - sparklineSafetyMargin - if w > sparklineMaxWidth { - w = sparklineMaxWidth - } if w < 8 { w = 8 } @@ -262,13 +249,20 @@ func padLabelRight(label string, width int) string { return label + strings.Repeat(" ", pad) } -func panelInnerWidth(width int) int { +func panelWidth(width int) int { if width <= 0 { width = 80 } - inner := width - panelHorizontalChrome - if inner < 20 { + if width < 20 { return 20 } + return width +} + +func panelInnerWidth(width int) int { + inner := panelWidth(width) - panelHorizontalChrome + if inner < 16 { + return 16 + } return inner } diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go index 7de411c..6ac3704 100644 --- a/internal/tui/dashboard/overview_test.go +++ b/internal/tui/dashboard/overview_test.go @@ -6,6 +6,7 @@ import ( "time" "ior/internal/statsengine" + common "ior/internal/tui/common" "charm.land/lipgloss/v2" ) @@ -121,23 +122,22 @@ func TestRenderOverviewDoesNotOverflowWidth(t *testing.T) { func TestRenderOverviewSparklineHasSafetyMargin(t *testing.T) { const panelInner = 80 out := renderOverviewSparkline("Latency:", []float64{1, 2, 3, 4, 5}, panelInner) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got, max := lipgloss.Width(lines[0]), panelInner-sparklineSafetyMargin; got > max { + if got, max := lipgloss.Width(out), panelInner-sparklineSafetyMargin; got > max { t.Fatalf("expected sparkline width <= %d with safety margin, got %d", max, got) } } -func TestRenderOverviewSparklineCapsWidth(t *testing.T) { +func TestRenderOverviewSparklineUsesAvailableWidth(t *testing.T) { out := renderOverviewSparkline("Latency:", make([]float64, 120), 400) - lines := strings.Split(out, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2-line sparkline, got %q", out) + if strings.Contains(out, "\n") { + t.Fatalf("expected single-line sparkline, got %q", out) } - if got := lipgloss.Width(lines[0]) - len("Latency: "); got > sparklineMaxWidth { - t.Fatalf("expected capped sparkline width <= %d, got %d", sparklineMaxWidth, got) + want := 400 - len("Latency:") - 1 - sparklineSafetyMargin + if got := lipgloss.Width(out) - len("Latency: "); got != want { + t.Fatalf("expected sparkline width %d, got %d", want, got) } } @@ -164,3 +164,14 @@ func TestRenderOverviewSparklineAlignedUsesSameSparkStartColumn(t *testing.T) { t.Fatalf("unexpected throughput prefix: %q", thrTop) } } + +func TestRenderOverviewSparklineAlignedFitsSinglePanelRow(t *testing.T) { + panelW := panelWidth(220) + panelInner := panelInnerWidth(220) + labelWidth := maxLabelWidth("Latency:", "Gap:", "Throughput:") + line := renderOverviewSparklineAligned("Latency:", []float64{0, 10, 5, 10, 0}, panelInner, labelWidth) + rendered := common.PanelStyle.Width(panelW).Render(line) + if got := len(strings.Split(rendered, "\n")); got != 3 { + t.Fatalf("expected sparkline to fit one panel row (3 total lines with border), got %d lines", got) + } +} diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go index 2ce8c90..ab78cce 100644 --- a/internal/tui/dashboard/sparkline.go +++ b/internal/tui/dashboard/sparkline.go @@ -1,9 +1,8 @@ package dashboard import "math" -import "strings" -var sparkRowChars = []rune(" ▁▂▃▄▅▆▇█") +var sparkChars = []rune("▁▂▃▄▅▆▇█") func renderSparkline(data []float64, width int) string { if len(data) == 0 || width <= 0 { @@ -11,23 +10,15 @@ func renderSparkline(data []float64, width int) string { } samples := sampleForWidth(data, width) - leftPad := 0 - if len(samples) < width { - leftPad = width - len(samples) - } min, max := minMax(samples) if min == max { - top := repeatRune(' ', width) - bottom := repeatRune(' ', leftPad) + repeatRune('█', len(samples)) - return top + "\n" + bottom + if min == 0 { + return repeatRune(' ', width) + } + return repeatRune('▁', width) } - top := make([]rune, width) - bottom := make([]rune, width) - for i := 0; i < leftPad; i++ { - top[i] = ' ' - bottom[i] = ' ' - } + row := make([]rune, width) scale := 16.0 denom := max - min for i, value := range samples { @@ -39,20 +30,17 @@ func renderSparkline(data []float64, width int) string { level = 16 } - topLevel := level - 8 - if topLevel < 0 { - topLevel = 0 + // Collapse the previous two-row 0..16 scale to a single-row 0..7 scale. + oneRow := level / 2 + if oneRow < 0 { + oneRow = 0 } - bottomLevel := level - if bottomLevel > 8 { - bottomLevel = 8 + if oneRow > 7 { + oneRow = 7 } - - col := leftPad + i - top[col] = sparkRowChars[topLevel] - bottom[col] = sparkRowChars[bottomLevel] + row[i] = sparkChars[oneRow] } - return string(top) + "\n" + string(bottom) + return string(row) } func renderLabeledSparkline(label string, data []float64, width int) string { @@ -60,20 +48,47 @@ func renderLabeledSparkline(label string, data []float64, width int) string { if spark == "" { return label } - lines := strings.Split(spark, "\n") - if len(lines) == 1 { - return label + " " + lines[0] - } - pad := repeatRune(' ', len([]rune(label))+1) - return label + " " + lines[0] + "\n" + pad + lines[1] + return label + " " + spark } func sampleForWidth(data []float64, width int) []float64 { - if width >= len(data) { + if width <= 0 || len(data) == 0 { + return nil + } + + if width < len(data) { + start := len(data) - width + return append([]float64(nil), data[start:]...) + } + + if width == len(data) { return append([]float64(nil), data...) } - start := len(data) - width - return append([]float64(nil), data[start:]...) + + if len(data) == 1 { + out := make([]float64, width) + for i := range out { + out[i] = data[0] + } + return out + } + + out := make([]float64, width) + srcLast := len(data) - 1 + dstLast := width - 1 + for i := 0; i < width; i++ { + // Nearest-neighbor upsampling preserves the original series shape + // without introducing interpolated spikes between samples. + srcIdx := int(math.Round(float64(i) * float64(srcLast) / float64(dstLast))) + if srcIdx < 0 { + srcIdx = 0 + } + if srcIdx > srcLast { + srcIdx = srcLast + } + out[i] = data[srcIdx] + } + return out } func minMax(values []float64) (float64, float64) { diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go index d7acd33..6f549d1 100644 --- a/internal/tui/dashboard/sparkline_test.go +++ b/internal/tui/dashboard/sparkline_test.go @@ -16,37 +16,52 @@ func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { func TestRenderSparklineSingleValue(t *testing.T) { got := renderSparkline([]float64{10}, 8) - if got != " \n █" { - t.Fatalf("expected two-line constant sparkline, got %q", got) + if got != "▁▁▁▁▁▁▁▁" { + t.Fatalf("expected single-line constant sparkline, got %q", got) } } func TestRenderSparklineAllEqualValues(t *testing.T) { got := renderSparkline([]float64{5, 5, 5, 5}, 4) - if got != " \n████" { - t.Fatalf("expected two-line flat sparkline, got %q", got) + if got != "▁▁▁▁" { + t.Fatalf("expected single-line flat sparkline, got %q", got) } } -func TestRenderSparklineRightAlignsShortHistory(t *testing.T) { +func TestRenderSparklineAllZeroValuesRendersBlank(t *testing.T) { + got := renderSparkline([]float64{0, 0, 0}, 5) + if got != " " { + t.Fatalf("expected blank sparkline for all-zero series, got %q", got) + } +} + +func TestRenderSparklineLeftAlignsShortHistory(t *testing.T) { got := renderSparkline([]float64{1, 2, 3}, 6) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) + first := strings.IndexFunc(got, func(r rune) bool { return r != ' ' }) + last := strings.LastIndexFunc(got, func(r rune) bool { return r != ' ' }) + if first < 0 || last < 0 { + t.Fatalf("expected visible sparkline cells, got %q", got) + } + if strings.HasPrefix(got, " ") { + t.Fatalf("expected sparkline not to use old right-aligned padding, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected left padding for short history, got %q", lines[1]) +} + +func TestRenderSparklineUsesRightmostColumn(t *testing.T) { + got := renderSparkline([]float64{1, 3, 2, 5}, 20) + row := []rune(got) + if len(row) != 20 { + t.Fatalf("expected 20 columns, got %d", len(row)) + } + if row[19] == ' ' { + t.Fatalf("expected rightmost column to contain sparkline data, got %q", got) } } func TestRenderSparklineRespectsWidthTruncation(t *testing.T) { got := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 4) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if len([]rune(lines[0])) != 4 || len([]rune(lines[1])) != 4 { - t.Fatalf("expected 4 runes per line, got %q", got) + if len([]rune(got)) != 4 { + t.Fatalf("expected 4 runes, got %q", got) } } @@ -63,27 +78,32 @@ func TestSampleForWidthUsesRecentTail(t *testing.T) { } } +func TestSampleForWidthUpsamplesToFullWidth(t *testing.T) { + got := sampleForWidth([]float64{10, 20, 30}, 7) + if len(got) != 7 { + t.Fatalf("expected 7 samples, got %d", len(got)) + } + if got[0] != 10 { + t.Fatalf("expected first sample to preserve series start, got %v", got[0]) + } + if got[len(got)-1] != 30 { + t.Fatalf("expected last sample to preserve series end, got %v", got[len(got)-1]) + } +} + func TestRenderSparklineSpansLowToHigh(t *testing.T) { got := renderSparkline([]float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.Contains(got, "█") { - t.Fatalf("expected high bar, got %q", got) + if got != "▁█" { + t.Fatalf("expected low-to-high one-row sparkline, got %q", got) } } -func TestRenderLabeledSparklineAlignsSecondRow(t *testing.T) { +func TestRenderLabeledSparklineSingleLine(t *testing.T) { got := renderLabeledSparkline("Latency:", []float64{0, 10}, 2) - lines := strings.Split(got, "\n") - if len(lines) != 2 { - t.Fatalf("expected 2 lines, got %q", got) - } - if !strings.HasPrefix(lines[0], "Latency: ") { - t.Fatalf("expected label prefix on first row, got %q", lines[0]) + if strings.Contains(got, "\n") { + t.Fatalf("expected single-line labeled sparkline, got %q", got) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected padding on second row to align sparkline, got %q", lines[1]) + if !strings.HasPrefix(got, "Latency: ") { + t.Fatalf("expected label prefix, got %q", got) } } |
