summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-09 08:05:26 +0200
committerPaul Buetow <paul@buetow.org>2026-03-09 08:05:26 +0200
commit9f35d16940f35591dd0b0c782d2ec8e57bba84b5 (patch)
tree031bd61cd47299ed66736012370541f4af760ceb
parent1277f03a01fafd5ce7931bf9d48dc92f089c6894 (diff)
tui: harden paused flame rendering
-rw-r--r--docs/tui-flamegraph-behavior.md46
-rw-r--r--internal/tui/dashboard/model.go14
-rw-r--r--internal/tui/dashboard/model_test.go57
-rw-r--r--internal/tui/flamegraph/model.go3
-rw-r--r--internal/tui/flamegraph/model_test.go30
5 files changed, 145 insertions, 5 deletions
diff --git a/docs/tui-flamegraph-behavior.md b/docs/tui-flamegraph-behavior.md
new file mode 100644
index 0000000..cc9bb5d
--- /dev/null
+++ b/docs/tui-flamegraph-behavior.md
@@ -0,0 +1,46 @@
+# TUI Flamegraph Expected Behavior
+
+This document records the expected interaction and layout behavior for the TUI
+flamegraph. It is intended as a stable reference for regressions and for tests
+under `internal/tui/flamegraph/` and `internal/tui/dashboard/`.
+
+## Interaction
+
+- `space` toggles pause. `p` does not pause the flamegraph and remains reserved
+ for the global PID picker at the top-level TUI.
+- `enter` and left-click zoom into the selected or clicked frame.
+- Clicking an ancestor frame in the zoom lineage re-roots the view to that
+ ancestor.
+- `u`, `backspace`, and `esc` undo one zoom step.
+- Direct clicks into a deep descendant create a single undo step back to the
+ previous zoom root, not an implicit stack of every skipped ancestor.
+- While paused, navigation and zoom must continue to work against the frozen
+ snapshot.
+
+## Layout
+
+- The selected frame must not render with underline or a horizontal highlight
+ line across the bar.
+- The current zoom root must span the full flamegraph width.
+- The children of the current zoom root must be normalized to the full viewport
+ width, even when the zoom root has self time or exclusive weight.
+- Zooming from any direction must produce the same full-width result for the
+ newly selected zoom root.
+- The zoom lineage rows shown above the zoomed subtree provide context, but they
+ must not steal horizontal space from the zoomed subtree.
+
+## Rendering
+
+- Rendering the dashboard view must not mutate persistent flamegraph state.
+- Redundant same-size viewport updates must be no-ops.
+- In paused mode, repeated renders must not reintroduce stale frame geometry or
+ leave artifacts from a previous layout on screen.
+
+## Regression Coverage
+
+These expectations are covered by tests in:
+
+- `internal/tui/flamegraph/renderer_test.go`
+- `internal/tui/flamegraph/model_test.go`
+- `internal/tui/flamegraph/stress_test.go`
+- `internal/tui/dashboard/model_test.go`
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 3a19b74..55661fb 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -752,7 +752,8 @@ func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) {
m.liveTrie = liveTrie
m.flamegraphModel.SetLiveTrie(liveTrie)
if m.width > 0 && m.height > 0 {
- m.flamegraphModel.SetViewport(m.width, m.height)
+ flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp)
+ m.flamegraphModel.SetViewport(flameWidth, flameHeight)
}
m.flamegraphModel.RefreshFromLiveTrie()
}
@@ -797,15 +798,19 @@ func (m Model) View() tea.View {
width, height := common.EffectiveViewport(m.width, m.height)
_, activeHeight := flameViewport(width, height, m.showHelp)
streamModel := m.streamModel
+ flameModel := m.flamegraphModel
streamModel.SetFooterVisible(m.showHelp)
if m.activeTab == TabStream {
_, activeHeight = streamViewport(width, height)
}
+ if m.activeTab == TabFlame {
+ flameModel.SetViewport(width, activeHeight)
+ }
var b strings.Builder
b.WriteString(renderTabBar(m.activeTab, width))
b.WriteString("\n")
- b.WriteString(m.renderActiveContent(width, activeHeight, &streamModel))
+ b.WriteString(m.renderActiveContent(width, activeHeight, &streamModel, &flameModel))
b.WriteString("\n")
if m.showHelp {
b.WriteString(renderHelpBarWithStatus(m.keys, width, m.filterSummary()))
@@ -823,7 +828,7 @@ func (m Model) filterSummary() string {
return summary + " | stack: " + strings.Join(m.filterStack, " | ")
}
-func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string {
+func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model, flameModel *flamegraphtui.Model) string {
if m.activeTab == TabSyscalls && m.syscallsVizMode == tabVizModeTreemap {
return renderSyscallsTreemap(m.latest, width, activeHeight, m.syscallsChart.Metric(), m.syscallsTreemapSelection, m.isDark)
}
@@ -853,7 +858,7 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
m.activeTab,
m.latest,
streamModel,
- &m.flamegraphModel,
+ flameModel,
width,
activeHeight,
m.pidFilter,
@@ -1091,7 +1096,6 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre
if flameModel == nil {
return common.PanelStyle.Render("Flame: waiting for model...")
}
- flameModel.SetViewport(width, height)
return flameModel.View().Content
}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index dbcde07..dc4ac93 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -16,6 +16,8 @@ import (
tea "charm.land/bubbletea/v2"
)
+var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
type fakeSnapshotSource struct {
snapshots int
snap *statsengine.Snapshot
@@ -41,6 +43,19 @@ func (f *fakeResettableSnapshotSource) Snapshot() *statsengine.Snapshot {
return f.snap
}
+func stripANSIEscape(value string) string {
+ return ansiEscapePattern.ReplaceAllString(value, "")
+}
+
+func firstLineContaining(value, needle string) string {
+ for _, line := range strings.Split(value, "\n") {
+ if strings.Contains(line, needle) {
+ return line
+ }
+ }
+ return ""
+}
+
func TestKeySwitchingChangesActiveTab(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
@@ -425,6 +440,48 @@ func TestFlameTickPausedFreezesAfterInitialSnapshot(t *testing.T) {
}
}
+func TestPausedFlameDashboardViewPreservesZoomedSelectedLine(t *testing.T) {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ coreflamegraph.SeedTestFlameData(liveTrie)
+
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFlame
+
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m = next.(Model)
+ m.SetLiveTrie(liveTrie)
+
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
+ m = next.(Model)
+
+ if !m.flamegraphModel.Paused() {
+ t.Fatalf("expected flamegraph model to be paused")
+ }
+
+ flameView := stripANSIEscape(m.flamegraphModel.View().Content)
+ selectedLine := firstLineContaining(flameView, "Selected:")
+ if selectedLine == "" {
+ t.Fatalf("expected flame view to include a selected line, got %q", flameView)
+ }
+ if !strings.Contains(selectedLine, "width=") {
+ t.Fatalf("expected selected line to include width details, got %q", selectedLine)
+ }
+
+ dashboardView := stripANSIEscape(m.View().Content)
+ if !strings.Contains(dashboardView, selectedLine) {
+ t.Fatalf("expected dashboard view to preserve paused zoom selected line %q, got %q", selectedLine, dashboardView)
+ }
+
+ dashboardViewAgain := stripANSIEscape(m.View().Content)
+ if !strings.Contains(dashboardViewAgain, selectedLine) {
+ t.Fatalf("expected repeated dashboard view to preserve paused zoom selected line %q, got %q", selectedLine, dashboardViewAgain)
+ }
+}
+
func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
rb := eventstream.NewRingBuffer()
for i := 0; i < 300; i++ {
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index d73bd65..16f82b6 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -443,6 +443,9 @@ func (m Model) Paused() bool {
// SetViewport updates model render dimensions.
func (m *Model) SetViewport(width, height int) {
+ if m.width == width && m.height == height {
+ return
+ }
m.width = width
m.height = height
m.rebuildFrames(true)
diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go
index 2f98b73..f83ccff 100644
--- a/internal/tui/flamegraph/model_test.go
+++ b/internal/tui/flamegraph/model_test.go
@@ -1130,6 +1130,36 @@ func TestResizeRecalculatesLayoutAndCullsNarrowFrames(t *testing.T) {
}
}
+func TestSetViewportSameSizeKeepsPausedZoomLayoutStable(t *testing.T) {
+ m := newZoomModel()
+ m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter})
+ m.paused = true
+
+ rootIdx := mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ if got, want := m.frames[rootIdx].Width, m.width; got != want {
+ t.Fatalf("expected zoom root to span full width before redundant viewport set, got %d want %d", got, want)
+ }
+
+ beforeFrames := append([]tuiFrame(nil), m.frames...)
+ beforeTargets := append([]tuiFrame(nil), m.targetFrames...)
+ m.SetViewport(m.width, m.height)
+
+ if m.animating {
+ t.Fatalf("expected redundant viewport set to avoid starting animation")
+ }
+ if !reflect.DeepEqual(m.frames, beforeFrames) {
+ t.Fatalf("expected redundant viewport set to preserve current frames")
+ }
+ if !reflect.DeepEqual(m.targetFrames, beforeTargets) {
+ t.Fatalf("expected redundant viewport set to preserve target frames")
+ }
+ rootIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A")
+ if got, want := m.frames[rootIdx].Width, m.width; got != want {
+ t.Fatalf("expected zoom root to remain full width after redundant viewport set, got %d want %d", got, want)
+ }
+}
+
func newZoomModel() Model {
m := NewModel(nil)
m.width = 120