diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 09:47:28 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 09:47:28 +0200 |
| commit | 81ffb947201690088ef25a1839a8993bbfc27f03 (patch) | |
| tree | 948400add2b7df214c1587f04fe4ec9bd51c439a /internal | |
| parent | ad4d7fca20d80f71ccabef3281e3f80081f4db62 (diff) | |
tui: fix responsive layout and stream viewport chrome
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/common/styles.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 15 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram_test.go | 31 | ||||
| -rw-r--r-- | internal/tui/dashboard/layout.go | 3 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 22 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview.go | 46 | ||||
| -rw-r--r-- | internal/tui/dashboard/overview_test.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline.go | 22 | ||||
| -rw-r--r-- | internal/tui/dashboard/sparkline_test.go | 8 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 103 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 23 |
12 files changed, 269 insertions, 54 deletions
diff --git a/internal/tui/common/styles.go b/internal/tui/common/styles.go index ed6a191..d4c75ff 100644 --- a/internal/tui/common/styles.go +++ b/internal/tui/common/styles.go @@ -16,8 +16,7 @@ var ( var ( // ScreenStyle is the base style for full-screen models. ScreenStyle = lipgloss.NewStyle(). - Foreground(ColorText). - Background(ColorBackground) + Foreground(ColorText) // HeaderStyle is used by top-level titles and screen headers. HeaderStyle = lipgloss.NewStyle(). @@ -34,7 +33,6 @@ var ( // TabInactiveStyle is applied to non-selected tabs. TabInactiveStyle = lipgloss.NewStyle(). Foreground(ColorMuted). - Background(ColorPanel). Padding(0, 1) // PanelStyle is used for boxed sections. diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go index 47942fe..7613230 100644 --- a/internal/tui/dashboard/histogram.go +++ b/internal/tui/dashboard/histogram.go @@ -14,8 +14,11 @@ func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Latency: waiting for stats...") } + panelInner := panelInnerWidth(width) hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) - spark := common.PanelStyle.Render(renderLabeledSparkline("Latency sparkline:", snap.LatencySeriesNs(), sparklineWidth(width))) + spark := common.PanelStyle.Width(panelInner).Render( + renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner), + ) return strings.Join([]string{hist, spark}, "\n") } @@ -24,8 +27,11 @@ func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { return common.PanelStyle.Render("Gaps: waiting for stats...") } + panelInner := panelInnerWidth(width) hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) - spark := common.PanelStyle.Render(renderLabeledSparkline("Gap sparkline:", snap.GapSeriesNs(), sparklineWidth(width))) + spark := common.PanelStyle.Width(panelInner).Render( + renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner), + ) return strings.Join([]string{hist, spark}, "\n") } @@ -47,6 +53,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he if width <= 0 { width = 80 } + panelInner := panelInnerWidth(width) if height > 0 { maxRows := height - 3 @@ -73,7 +80,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he } } - barWidth := width - labelWidth - countWidth - 10 + barWidth := panelInner - labelWidth - countWidth - 6 if barWidth < 8 { barWidth = 8 } @@ -86,7 +93,7 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he } lines = append(lines, "Scale: █▓▒░") - return common.PanelStyle.Render(strings.Join(lines, "\n")) + return common.PanelStyle.Width(panelInner).Render(strings.Join(lines, "\n")) } func renderHistogramBar(count, maxCount uint64, width int) string { diff --git a/internal/tui/dashboard/histogram_test.go b/internal/tui/dashboard/histogram_test.go index 9da1c47..7790394 100644 --- a/internal/tui/dashboard/histogram_test.go +++ b/internal/tui/dashboard/histogram_test.go @@ -5,6 +5,8 @@ import ( "testing" "ior/internal/statsengine" + + "github.com/charmbracelet/lipgloss" ) func TestRenderHistogramNoBuckets(t *testing.T) { @@ -74,3 +76,32 @@ func TestRenderHistogramTruncatesForSmallHeight(t *testing.T) { t.Fatalf("expected histogram rows to be truncated for small height: %q", out) } } + +func TestRenderLatencyGapsTabDoesNotOverflowWidth(t *testing.T) { + snap := statsengine.NewSnapshot( + []float64{10, 20, 15, 30, 18, 35}, + []float64{2, 4, 3, 5, 7, 6}, + nil, + nil, + nil, + nil, + statsengine.NewHistogramSnapshot(6, []statsengine.HistogramBucketSnapshot{ + {Label: "[0,1us)", Count: 1}, + {Label: "[1us,10us)", Count: 2}, + {Label: "[10us,100us)", Count: 3}, + }), + statsengine.NewHistogramSnapshot(6, []statsengine.HistogramBucketSnapshot{ + {Label: "[0,1us)", Count: 1}, + {Label: "[1us,10us)", Count: 2}, + {Label: "[10us,100us)", Count: 3}, + }), + ) + + const width = 100 + out := renderLatencyGapsTab(&snap, width, 24) + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("latency/gaps line exceeds width %d: got %d in %q", width, lipgloss.Width(line), line) + } + } +} diff --git a/internal/tui/dashboard/layout.go b/internal/tui/dashboard/layout.go new file mode 100644 index 0000000..38cce18 --- /dev/null +++ b/internal/tui/dashboard/layout.go @@ -0,0 +1,3 @@ +package dashboard + +const panelHorizontalChrome = 4 diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 9b425b1..2ed53a1 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -14,6 +14,7 @@ import ( const defaultRefreshMs = 1000 const streamRefreshMs = 200 +const streamChromeRows = 4 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -73,7 +74,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height - m.streamModel.SetViewport(msg.Width, msg.Height) + streamWidth, streamHeight := streamViewport(msg.Width, msg.Height) + m.streamModel.SetViewport(streamWidth, streamHeight) return m, nil case refreshTickMsg: snap := m.snapshot() @@ -188,7 +190,7 @@ func (m *Model) handleScrollKey(msg tea.KeyMsg) bool { case TabProcesses: return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()) case TabStream: - streamWidth, streamHeight := common.EffectiveViewport(m.width, m.height) + streamWidth, streamHeight := streamViewport(m.width, m.height) m.streamModel.SetViewport(streamWidth, streamHeight) return m.streamModel.HandleTeaKey(msg) default: @@ -267,6 +269,10 @@ 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) + activeHeight := height + if m.activeTab == TabStream { + _, activeHeight = streamViewport(width, height) + } var b strings.Builder b.WriteString(renderTabBar(m.activeTab, width)) @@ -276,7 +282,7 @@ func (m Model) View() string { m.latest, &m.streamModel, width, - height, + activeHeight, m.syscallsOffset, m.filesOffset, m.filesDirGrouped, @@ -286,7 +292,7 @@ func (m Model) View() string { b.WriteString("\n") b.WriteString(common.HighlightStyle.Render("Press ? for help")) b.WriteString("\n") - b.WriteString(renderHelpBar(m.keys)) + b.WriteString(renderHelpBar(m.keys, width)) return common.ScreenStyle.Render(b.String()) } @@ -328,3 +334,12 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre func streamTickCmd() tea.Cmd { return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} }) } + +func streamViewport(width, height int) (int, int) { + width, height = common.EffectiveViewport(width, height) + height -= streamChromeRows + if height < 1 { + height = 1 + } + return width, height +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 1e54b27..6baa62c 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -390,3 +390,25 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) { t.Fatalf("expected grouped directory files view header, got %q", out) } } + +func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) { + rb := eventstream.NewRingBuffer() + for i := 0; i < 200; i++ { + rb.Push(eventstream.StreamEvent{Syscall: "read"}) + } + + m := NewModelWithConfig(nil, rb, 1000, common.DefaultKeyMap()) + m.activeTab = TabStream + m.width = 120 + m.height = 30 + m.streamModel.SetSource(rb) + m.streamModel.Refresh() + + out := m.View() + if !strings.Contains(out, "1:Overview") { + t.Fatalf("expected tab bar to remain visible in stream view") + } + if !strings.Contains(out, "Press ? for help") { + t.Fatalf("expected help hint to remain visible in stream view") + } +} diff --git a/internal/tui/dashboard/overview.go b/internal/tui/dashboard/overview.go index f4ec5bc..9a77da0 100644 --- a/internal/tui/dashboard/overview.go +++ b/internal/tui/dashboard/overview.go @@ -6,6 +6,7 @@ import ( common "ior/internal/tui/common" "strings" "time" + "unicode/utf8" "github.com/charmbracelet/lipgloss" ) @@ -32,16 +33,17 @@ func renderOverview(snap *statsengine.Snapshot, width, height int) string { trendWithArrow(snap.ThroughputTrend), ) - latencySpark := renderLabeledSparkline("Latency:", snap.LatencySeriesNs(), sparklineWidth(width)) - gapSpark := renderLabeledSparkline("Gap:", snap.GapSeriesNs(), sparklineWidth(width)) - throughputSpark := renderLabeledSparkline("Throughput:", snap.ThroughputSeriesB(), sparklineWidth(width)) + panelInner := panelInnerWidth(width) + latencySpark := renderOverviewSparkline("Latency:", snap.LatencySeriesNs(), panelInner) + gapSpark := renderOverviewSparkline("Gap:", snap.GapSeriesNs(), panelInner) + throughputSpark := renderOverviewSparkline("Throughput:", snap.ThroughputSeriesB(), panelInner) topSyscalls := "Top syscalls: " + summarizeTopSyscalls(snap) topFiles := "Top files: " + summarizeTopFiles(snap) topProcesses := "Top processes: " + summarizeTopProcesses(snap) latencyHist := "Latency buckets: " + summarizeHistogramBrief(snap.LatencyHistogram) gapHist := "Gap buckets: " + summarizeHistogramBrief(snap.GapHistogram) - panel := common.PanelStyle.Width(width) + panel := common.PanelStyle.Width(panelInner) 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")) @@ -70,7 +72,7 @@ func renderSyscallBox(snap *statsengine.Snapshot, width int) string { snap.SyscallRatePerSec, generatedAt, ) - return common.PanelStyle.Width(width).Height(5).Render(content) + return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) } func renderBytesBox(snap *statsengine.Snapshot, width int) string { @@ -80,7 +82,7 @@ func renderBytesBox(snap *statsengine.Snapshot, width int) string { formatBytes(snap.WriteBytesPerSec), formatBytes(float64(snap.TotalBytes)), ) - return common.PanelStyle.Width(width).Height(5).Render(content) + return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) } func renderErrorBox(snap *statsengine.Snapshot, width int) string { @@ -96,7 +98,7 @@ func renderErrorBox(snap *statsengine.Snapshot, width int) string { snap.LatencyMeanNs, snap.GapMeanNs, ) - return common.PanelStyle.Width(width).Height(5).Render(content) + return common.PanelStyle.Width(summaryBoxInnerWidth(width)).Height(5).Render(content) } func trendWithArrow(trend statsengine.Trend) string { @@ -202,20 +204,36 @@ func summaryBoxWidth(width int) int { if width <= 0 { return 24 } - w := (width - 4) / 3 + w := width / 3 if w < 18 { return 18 } return w } -func sparklineWidth(width int) int { - if width <= 0 { - return 20 +func summaryBoxInnerWidth(width int) int { + inner := width - panelHorizontalChrome + if inner < 14 { + return 14 } - w := width - 14 + return inner +} + +func renderOverviewSparkline(label string, data []float64, panelInner int) string { + w := panelInner - utf8.RuneCountInString(label) - 1 if w < 8 { - return 8 + w = 8 } - return w + return renderLabeledSparkline(label, data, w) +} + +func panelInnerWidth(width int) int { + if width <= 0 { + width = 80 + } + inner := width - panelHorizontalChrome + if inner < 20 { + return 20 + } + return inner } diff --git a/internal/tui/dashboard/overview_test.go b/internal/tui/dashboard/overview_test.go index cee6cf2..89f00fb 100644 --- a/internal/tui/dashboard/overview_test.go +++ b/internal/tui/dashboard/overview_test.go @@ -6,6 +6,8 @@ import ( "time" "ior/internal/statsengine" + + "github.com/charmbracelet/lipgloss" ) func TestRenderOverviewIncludesCoreMetrics(t *testing.T) { @@ -94,3 +96,24 @@ func TestOverviewSummariesIncludeFilesProcessesAndHistograms(t *testing.T) { } } } + +func TestRenderOverviewDoesNotOverflowWidth(t *testing.T) { + snap := statsengine.NewSnapshot( + []float64{10, 20, 15, 30, 18, 35, 40}, + []float64{2, 4, 3, 5, 7, 6, 8}, + []float64{1024, 2048, 4096, 2048, 1024}, + []statsengine.SyscallSnapshot{{Name: "read", Count: 20}}, + []statsengine.FileSnapshot{{Path: "/tmp/very/long/path/to/file.log", Accesses: 10}}, + []statsengine.ProcessSnapshot{{PID: 42, Comm: "proc", Syscalls: 30}}, + statsengine.HistogramSnapshot{}, + statsengine.HistogramSnapshot{}, + ) + + const width = 120 + out := renderOverview(&snap, width, 30) + for _, line := range strings.Split(out, "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("overview line exceeds width %d: got %d in %q", width, lipgloss.Width(line), line) + } + } +} diff --git a/internal/tui/dashboard/sparkline.go b/internal/tui/dashboard/sparkline.go index fad86e2..5cc445d 100644 --- a/internal/tui/dashboard/sparkline.go +++ b/internal/tui/dashboard/sparkline.go @@ -11,23 +11,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)) + bottom := repeatRune('█', width) return top + "\n" + bottom } top := make([]rune, width) bottom := make([]rune, width) - for i := 0; i < leftPad; i++ { - top[i] = ' ' - bottom[i] = ' ' - } scale := 16.0 denom := max - min for i, value := range samples { @@ -51,7 +43,7 @@ func renderSparkline(data []float64, width int) string { bottomLevel = 1 } - col := leftPad + i + col := i top[col] = sparkRowChars[topLevel] bottom[col] = sparkRowChars[bottomLevel] } @@ -72,12 +64,16 @@ func renderLabeledSparkline(label string, data []float64, width int) string { } func sampleForWidth(data []float64, width int) []float64 { - if width >= len(data) { - return append([]float64(nil), data...) - } if width == 1 { return []float64{data[len(data)-1]} } + if len(data) == 1 { + out := make([]float64, width) + for i := range out { + out[i] = data[0] + } + return out + } last := len(data) - 1 samples := make([]float64, width) diff --git a/internal/tui/dashboard/sparkline_test.go b/internal/tui/dashboard/sparkline_test.go index e1fb316..66d1673 100644 --- a/internal/tui/dashboard/sparkline_test.go +++ b/internal/tui/dashboard/sparkline_test.go @@ -16,7 +16,7 @@ func TestRenderSparklineEmptyOrInvalidWidth(t *testing.T) { func TestRenderSparklineSingleValue(t *testing.T) { got := renderSparkline([]float64{10}, 8) - if got != " \n █" { + if got != " \n████████" { t.Fatalf("expected two-line constant sparkline, got %q", got) } } @@ -28,14 +28,14 @@ func TestRenderSparklineAllEqualValues(t *testing.T) { } } -func TestRenderSparklineRightAlignsShortHistory(t *testing.T) { +func TestRenderSparklineStretchesShortHistoryToWidth(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) } - if !strings.HasPrefix(lines[1], " ") { - t.Fatalf("expected left padding for short history, got %q", lines[1]) + if strings.HasPrefix(lines[1], " ") { + t.Fatalf("expected short history to fill width, got %q", lines[1]) } } diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index a2fe366..7f1908a 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -4,6 +4,7 @@ import ( "fmt" common "ior/internal/tui/common" "strings" + "unicode/utf8" "github.com/charmbracelet/lipgloss" ) @@ -77,28 +78,108 @@ func tabIndex(tab Tab) int { } func renderTabBar(active Tab, width int) string { - parts := make([]string, 0, len(allTabs)) - for i, tab := range allTabs { - label := fmt.Sprintf("%d:%s", i+1, tab.String()) - if tab == active { - parts = append(parts, common.TabActiveStyle.Render(label)) - } else { - parts = append(parts, common.TabInactiveStyle.Render(label)) + if width > 0 && width < 90 { + return renderTabBarPlain(active, width) + } + build := func(short bool) string { + parts := make([]string, 0, len(allTabs)) + for i, tab := range allTabs { + label := fmt.Sprintf("%d:%s", i+1, tabLabel(tab, short)) + if tab == active { + parts = append(parts, common.TabActiveStyle.Render(label)) + } else { + parts = append(parts, common.TabInactiveStyle.Render(label)) + } } + return lipgloss.JoinHorizontal(lipgloss.Left, parts...) } - bar := lipgloss.JoinHorizontal(lipgloss.Left, parts...) + bar := build(false) + if width > 0 && lipgloss.Width(bar) > width { + bar = build(true) + } + if width > 0 && lipgloss.Width(bar) > width { + label := fmt.Sprintf("%d:%s", tabIndex(active)+1, tabLabel(active, false)) + bar = common.TabActiveStyle.Render(label) + } if width <= 0 { return bar } - return lipgloss.NewStyle().Width(width).Render(bar) + styled := lipgloss.NewStyle().Width(width).Render(bar) + if strings.Contains(styled, "\n") { + return renderTabBarPlain(active, width) + } + return styled } -func renderHelpBar(keys common.KeyMap) string { +func renderHelpBar(keys common.KeyMap, width int) string { parts := make([]string, 0, len(keys.DashboardShortHelp())) for _, binding := range keys.DashboardShortHelp() { help := binding.Help() parts = append(parts, help.Key+" "+help.Desc) } - return common.HelpBarStyle.Render(strings.Join(parts, " • ")) + text := strings.Join(parts, " • ") + if width > 0 { + text = truncatePlain(text, width) + } + if width > 0 && width < 90 { + return text + } + return common.HelpBarStyle.Width(width).Render(text) +} + +func tabLabel(tab Tab, short bool) string { + if !short { + return tab.String() + } + switch tab { + case TabOverview: + return "Ovr" + case TabSyscalls: + return "Sys" + case TabFiles: + return "Fil" + case TabProcesses: + return "Pro" + case TabLatency: + return "Lat" + case TabStream: + return "Str" + default: + return "Unk" + } +} + +func truncatePlain(s string, width int) string { + if width <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= width { + return s + } + if width == 1 { + return "…" + } + r := []rune(s) + return string(r[:width-1]) + "…" +} + +func renderTabBarPlain(active Tab, width int) string { + parts := make([]string, 0, len(allTabs)) + for i, tab := range allTabs { + label := fmt.Sprintf("%d:%s", i+1, tabLabel(tab, true)) + if tab == active { + label = "[" + label + "]" + } + parts = append(parts, label) + } + text := strings.Join(parts, " ") + if width > 0 { + text = truncatePlain(text, width) + padding := width - utf8.RuneCountInString(text) + if padding > 0 { + text += strings.Repeat(" ", padding) + } + } + return text } diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 0fc36f2..bf96864 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -3,6 +3,8 @@ package dashboard import ( "strings" "testing" + + common "ior/internal/tui/common" ) func TestTabNavigationWraps(t *testing.T) { @@ -18,10 +20,29 @@ func TestTabNavigationWraps(t *testing.T) { } func TestRenderTabBarContainsLabels(t *testing.T) { - out := renderTabBar(TabOverview, 80) + out := renderTabBar(TabOverview, 100) for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream"} { if !strings.Contains(out, label) { t.Fatalf("expected tab label %q in tab bar", label) } } } + +func TestRenderTabBarSmallWidthUsesSingleLine(t *testing.T) { + out := renderTabBar(TabOverview, 70) + lines := strings.Split(out, "\n") + if len(lines) != 1 { + t.Fatalf("expected single-line tab bar at width 70, got %d lines", len(lines)) + } + if strings.Contains(out, "6:Strea") { + t.Fatalf("tab label should not be wrapped/split in small width output") + } +} + +func TestRenderHelpBarSmallWidthUsesSingleLine(t *testing.T) { + out := renderHelpBar(common.DefaultKeyMap(), 70) + lines := strings.Split(out, "\n") + if len(lines) != 1 { + t.Fatalf("expected single-line help bar at width 70, got %d lines", len(lines)) + } +} |
