diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-09 08:05:26 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-09 08:05:26 +0200 |
| commit | 9f35d16940f35591dd0b0c782d2ec8e57bba84b5 (patch) | |
| tree | 031bd61cd47299ed66736012370541f4af760ceb /internal | |
| parent | 1277f03a01fafd5ce7931bf9d48dc92f089c6894 (diff) | |
tui: harden paused flame rendering
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/dashboard/model.go | 14 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 57 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 3 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 30 |
4 files changed, 99 insertions, 5 deletions
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 |
