diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 09:36:57 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 09:36:57 +0200 |
| commit | 72ff234e97b16485553a79a876690a359058b110 (patch) | |
| tree | 7b5133489ff48b0dee6857f4df2e82a704b8768c /internal/tui | |
| parent | 1279ffb8f2efba54ff005cce91ba65c149cb1ee6 (diff) | |
Fix initial TUI sizing and align two-row sparklines
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/viewport.go | 38 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 53 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 38 | ||||
| -rw-r--r-- | internal/tui/tui.go | 31 |
7 files changed, 137 insertions, 39 deletions
diff --git a/internal/tui/common/viewport.go b/internal/tui/common/viewport.go new file mode 100644 index 0000000..e1729db --- /dev/null +++ b/internal/tui/common/viewport.go @@ -0,0 +1,38 @@ +package common + +import ( + "os" + + xterm "github.com/charmbracelet/x/term" +) + +const ( + defaultViewportWidth = 80 + defaultViewportHeight = 24 +) + +// EffectiveViewport returns a usable terminal viewport size. Missing or invalid +// dimensions are resolved from the active terminal when possible. +func EffectiveViewport(width, height int) (int, int) { + if width > 0 && height > 0 { + return width, height + } + + termWidth, termHeight, err := xterm.GetSize(os.Stdout.Fd()) + if err == nil { + if width <= 0 && termWidth > 0 { + width = termWidth + } + if height <= 0 && termHeight > 0 { + height = termHeight + } + } + + if width <= 0 { + width = defaultViewportWidth + } + if height <= 0 { + height = defaultViewportHeight + } + return width, height +} diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 1e68a7b..47942fe 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -15,7 +15,7 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { } hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) - spark := common.PanelStyle.Render("Latency sparkline: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width))) + spark := common.PanelStyle.Render(renderLabeledSparkline("Latency sparkline:", snap.LatencySeriesNs(), sparklineWidth(width))) return strings.Join([]string{hist, spark}, "\n") } @@ -25,7 +25,7 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { } hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) - spark := common.PanelStyle.Render("Gap sparkline: " + renderSparkline(snap.GapSeriesNs(), sparklineWidth(width))) + spark := common.PanelStyle.Render(renderLabeledSparkline("Gap sparkline:", snap.GapSeriesNs(), sparklineWidth(width))) return strings.Join([]string{hist, spark}, "\n") } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 78da351..56e1980 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -241,10 +241,12 @@ func (m *Model) SetStreamSource(source *eventstream.RingBuffer) { // View renders the tab bar, active tab scaffold, and help bar. func (m Model) View() string { + width, height := common.EffectiveViewport(m.width, m.height) + var b strings.Builder - b.WriteString(renderTabBar(m.activeTab, m.width)) + b.WriteString(renderTabBar(m.activeTab, width)) b.WriteString("\n") - b.WriteString(renderActiveTab(m.activeTab, m.latest, &m.streamModel, m.width, m.height, m.syscallsOffset, m.filesOffset, m.processesOffset)) + b.WriteString(renderActiveTab(m.activeTab, m.latest, &m.streamModel, width, height, m.syscallsOffset, m.filesOffset, m.processesOffset)) b.WriteString("\n") b.WriteString(common.HighlightStyle.Render("Press ? for help")) b.WriteString("\n") diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go index 9feafab..7cbc7fe 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -29,9 +29,9 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { trendWithArrow(snap.ThroughputTrend), ) - latencySpark := "Latency: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width)) - gapSpark := "Gap: " + renderSparkline(snap.GapSeriesNs(), sparklineWidth(width)) - throughputSpark := "Throughput: " + renderSparkline(snap.ThroughputSeriesB(), sparklineWidth(width)) + latencySpark := renderLabeledSparkline("Latency:", snap.LatencySeriesNs(), sparklineWidth(width)) + gapSpark := renderLabeledSparkline("Gap:", snap.GapSeriesNs(), sparklineWidth(width)) + throughputSpark := renderLabeledSparkline("Throughput:", snap.ThroughputSeriesB(), sparklineWidth(width)) topSyscalls := "Top syscalls: " + summarizeTopSyscalls(snap) topFiles := "Top files: " + summarizeTopFiles(snap) topProcesses := "Top processes: " + summarizeTopProcesses(snap) diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go index 9c1f2c4..b94d84f 100644 --- a/internal/tui/dashboard/sparkline.go +++ b/internal/tui/dashboard/sparkline.go @@ -1,8 +1,9 @@ package dashboard import "math" +import "strings" -var sparkChars = []rune("▁▂▃▄▅▆▇█") +var sparkRowChars = []rune(" ▁▂▃▄▅▆▇█") func renderSparkline(data []float64, width int) string { if len(data) == 0 || width <= 0 { @@ -11,27 +12,51 @@ func renderSparkline(data []float64, width int) string { samples := sampleForWidth(data, width) min, max := minMax(samples) - line := "" if min == max { - line = repeatRune('▄', len(samples)) - return line + "\n" + line + top := repeatRune(' ', len(samples)) + bottom := repeatRune('█', len(samples)) + return top + "\n" + bottom } - out := make([]rune, len(samples)) - scale := float64(len(sparkChars) - 1) + top := make([]rune, len(samples)) + bottom := make([]rune, len(samples)) + scale := 16.0 denom := max - min for i, value := range samples { - idx := int(math.Round((value - min) / denom * scale)) - if idx < 0 { - idx = 0 + level := int(math.Round((value - min) / denom * scale)) + if level < 0 { + level = 0 } - if idx >= len(sparkChars) { - idx = len(sparkChars) - 1 + if level > 16 { + level = 16 } - out[i] = sparkChars[idx] + + topLevel := level - 8 + if topLevel < 0 { + topLevel = 0 + } + bottomLevel := level + if bottomLevel > 8 { + bottomLevel = 8 + } + + top[i] = sparkRowChars[topLevel] + bottom[i] = sparkRowChars[bottomLevel] + } + return string(top) + "\n" + string(bottom) +} + +func renderLabeledSparkline(label string, data []float64, width int) string { + spark := renderSparkline(data, width) + if spark == "" { + return label + } + lines := strings.Split(spark, "\n") + if len(lines) == 1 { + return label + " " + lines[0] } - line = string(out) - return line + "\n" + line + pad := repeatRune(' ', len([]rune(label))+1) + return label + " " + lines[0] + "\n" + pad + lines[1] } func sampleForWidth(data []float64, width int) []float64 { diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go index d39d145..97dac03 100644 --- a/internal/tui/dashboard/sparkline_test.go +++ b/internal/tui/dashboard/sparkline_test.go @@ -16,28 +16,50 @@ func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { func TestRenderSparklineSingleValue(t *testing.T) { got := renderSparkline([]float64{10}, 8) - if got != "▄" { - t.Fatalf("expected single mid-bar rune, got %q", got) + if got != " \n█" { + t.Fatalf("expected two-line constant sparkline, got %q", got) } } func TestRenderSparklineAllEqualValues(t *testing.T) { got := renderSparkline([]float64{5, 5, 5, 5}, 4) - if got != "▄▄▄▄" { - t.Fatalf("expected flat sparkline, got %q", got) + if got != " \n████" { + t.Fatalf("expected two-line flat sparkline, got %q", got) } } func TestRenderSparklineRespectsWidthTruncation(t *testing.T) { got := renderSparkline([]float64{1, 2, 3, 4, 5, 6, 7, 8}, 4) - if len([]rune(got)) != 4 { - t.Fatalf("expected 4 runes, got %q", got) + 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) } } func TestRenderSparklineSpansLowToHigh(t *testing.T) { got := renderSparkline([]float64{0, 10}, 2) - if !strings.Contains(got, "▁") || !strings.Contains(got, "█") { - t.Fatalf("expected low/high bars, got %q", got) + 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) + } +} + +func TestRenderLabeledSparklineAlignsSecondRow(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.HasPrefix(lines[1], " ") { + t.Fatalf("expected padding on second row to align sparkline, got %q", lines[1]) } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 7e77a81..5aff6a2 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -7,6 +7,7 @@ import ( "fmt" "ior/internal/flags" "ior/internal/statsengine" + common "ior/internal/tui/common" dashboardui "ior/internal/tui/dashboard" "ior/internal/tui/eventstream" tuiexport "ior/internal/tui/export" @@ -144,10 +145,18 @@ func NewModel(initialPID int, startTrace TraceStarter) Model { // Init initializes the active child model and optional tracing startup command. func (m Model) Init() tea.Cmd { + sizeCmd := initialWindowSizeCmd() if m.screen == ScreenDashboard && m.attaching { - return tea.Batch(m.spin.Tick, m.beginTraceCmd()) + return tea.Batch(sizeCmd, tea.WindowSize(), m.spin.Tick, m.beginTraceCmd()) + } + return tea.Batch(sizeCmd, tea.WindowSize(), m.pidPicker.Init()) +} + +func initialWindowSizeCmd() tea.Cmd { + return func() tea.Msg { + width, height := common.EffectiveViewport(0, 0) + return tea.WindowSizeMsg{Width: width, Height: height} } - return m.pidPicker.Init() } // Update routes messages, transitions screens, and manages tracing startup state. @@ -288,34 +297,36 @@ func (m Model) View() string { return "" } + width, height := common.EffectiveViewport(m.width, m.height) + if m.attaching { line := fmt.Sprintf("%s Attaching tracepoints...", m.spin.View()) - return placeToViewport(m.width, m.height, ScreenStyle.Render(PanelStyle.Render(line))) + return placeToViewport(width, height, ScreenStyle.Render(PanelStyle.Render(line))) } if m.lastErr != nil { - return placeToViewport(m.width, m.height, ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))) + return placeToViewport(width, height, ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error()))) } switch m.screen { case ScreenPIDPicker: base := m.pidPicker.View() if m.exporter.Visible() { - return placeToViewport(m.width, m.height, m.exporter.View(m.width, m.height)+"\n"+base) + return placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base) } if m.showHelp { - return placeToViewport(m.width, m.height, renderHelpOverlay(m.width, m.height, [][]key.Binding{m.keys.PickerShortHelp()})+"\n"+base) + return placeToViewport(width, height, renderHelpOverlay(width, height, [][]key.Binding{m.keys.PickerShortHelp()})+"\n"+base) } - return placeToViewport(m.width, m.height, base) + return placeToViewport(width, height, base) case ScreenDashboard: base := m.dashboard.View() if m.exporter.Visible() { - return placeToViewport(m.width, m.height, m.exporter.View(m.width, m.height)+"\n"+base) + return placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base) } if m.showHelp { - return placeToViewport(m.width, m.height, renderHelpOverlay(m.width, m.height, m.keys.DashboardFullHelp())+"\n"+base) + return placeToViewport(width, height, renderHelpOverlay(width, height, m.keys.DashboardFullHelp())+"\n"+base) } - return placeToViewport(m.width, m.height, base) + return placeToViewport(width, height, base) default: return "" } |
