summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 09:36:57 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 09:36:57 +0200
commit72ff234e97b16485553a79a876690a359058b110 (patch)
tree7b5133489ff48b0dee6857f4df2e82a704b8768c /internal/tui
parent1279ffb8f2efba54ff005cce91ba65c149cb1ee6 (diff)
Fix initial TUI sizing and align two-row sparklines
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/viewport.go38
-rw-r--r--internal/tui/dashboard/histogram.go4
-rw-r--r--internal/tui/dashboard/model.go6
-rw-r--r--internal/tui/dashboard/overview.go6
-rw-r--r--internal/tui/dashboard/sparkline.go53
-rw-r--r--internal/tui/dashboard/sparkline_test.go38
-rw-r--r--internal/tui/tui.go31
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 ""
}