summaryrefslogtreecommitdiff
path: root/internal
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 /internal
parent1277f03a01fafd5ce7931bf9d48dc92f089c6894 (diff)
tui: harden paused flame rendering
Diffstat (limited to 'internal')
-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
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