diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 23:14:09 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 23:14:09 +0200 |
| commit | 106fcb3fe959966dec19d1242ff87df644a43fad (patch) | |
| tree | 5152e1d4dadbf991040d0db069c8d76db889364d /internal/tui/flamegraph | |
| parent | 013e46d7856a604d4890a880b8bbfb4b8c58202b (diff) | |
fix(tui): restore bubble modes and stabilize flame zoom lineage
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 60 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 108 |
3 files changed, 132 insertions, 38 deletions
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index e69d845..37b030a 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -89,7 +89,7 @@ func (m Model) toolbarLine() string { if m.statusMessage != "" { line += " | " + m.statusMessage } - if m.lastKeyDebug != "" { + if flameKeyDebugEnabled && m.lastKeyDebug != "" { line += " | " + m.lastKeyDebug } width := m.width diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 1d01f66..9c7bac5 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -26,6 +26,7 @@ type snapshotNode struct { type animTickMsg struct{} const animFrameDuration = 33 * time.Millisecond +const flameKeyDebugEnabled = false // LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model. type LiveTrieSource interface { @@ -467,7 +468,17 @@ func (m *Model) rebuildFrames(animate bool) { } else { root = m.snapshot } - targetFrames := buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + layoutWidth := m.width + if m.zoomPath != "" { + fallbackLineWidth := m.zoomLineWidth + if fallbackLineWidth <= 0 { + fallbackLineWidth = layoutWidth + } + if _, gutter, ok := m.zoomLineageGeometry(fallbackLineWidth); ok { + layoutWidth = m.width - gutter + } + } + targetFrames := buildTerminalLayoutWithPath(root, layoutWidth, m.height, rootPath) if m.zoomPath != "" { targetFrames = m.withZoomLineage(targetFrames) } @@ -544,7 +555,7 @@ func (m *Model) zoomIn() { m.zoomRoot = target m.zoomPath = selectedPath m.zoomLineWidth = selectedWidth - m.rebuildFrames(true) + m.rebuildFrames(false) m.statusMessage = "Zoom: " + compactFramePath(selectedPath) } @@ -564,7 +575,7 @@ func (m *Model) zoomUndo() { m.zoomLineWidth = last.lineWidth } m.selectedIdx = last.previousSelectedIdx - m.rebuildFrames(true) + m.rebuildFrames(false) if m.zoomPath == "" { m.statusMessage = "Zoom: root" return @@ -851,6 +862,9 @@ func (m *Model) ensureSelectionNavigable() { } func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) { + if !flameKeyDebugEnabled { + return + } keyID := keyString(msg) if keyID == "" { keyID = fmt.Sprintf("code:%d", msg.Code) @@ -1137,6 +1151,28 @@ func (m Model) frameIndexAt(x, y int) int { return best } +func (m Model) zoomLineageGeometry(fallbackLineWidth int) (lineWidth, gutter int, ok bool) { + if m.zoomPath == "" || m.width <= 0 { + return 0, 0, false + } + lineWidth = m.zoomLineWidth + if lineWidth <= 0 { + lineWidth = fallbackLineWidth + } + if lineWidth <= 0 { + lineWidth = m.width / 4 + } + lineWidth = min(max(lineWidth, 3), max(3, m.width/3)) + if lineWidth >= m.width-2 { + return 0, 0, false + } + gutter = lineWidth + 1 + if m.width-gutter < minFlameWidth/2 { + return 0, 0, false + } + return lineWidth, gutter, true +} + func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { if len(frames) == 0 || m.snapshot == nil { return frames @@ -1146,16 +1182,16 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { return frames } - lineWidth := m.zoomLineWidth - if lineWidth <= 0 { - lineWidth = frames[0].Width + fallbackLineWidth := 0 + if len(frames) > 0 { + fallbackLineWidth = frames[0].Width } - lineWidth = min(max(lineWidth, 3), max(3, m.width/3)) - if lineWidth >= m.width-2 { + _, gutter, ok := m.zoomLineageGeometry(fallbackLineWidth) + if !ok { return frames } - gutter := lineWidth + 1 - if m.width-gutter < minFlameWidth/2 { + lineageWidth := m.width - gutter + if lineageWidth < 1 { return frames } @@ -1186,9 +1222,9 @@ func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { name := parts[depth] out = append(out, tuiFrame{ Name: name, - Col: 0, + Col: gutter, Row: depth, - Width: lineWidth, + Width: lineageWidth, Total: total, Percent: percent, Fill: terminalFrameColor(name), diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index bbd2005..59130ec 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -276,7 +276,7 @@ func TestMouseClickOutsideBarsDoesNotChangeSelectionOrZoom(t *testing.T) { } } -func TestZoomKeepsNarrowLineageRail(t *testing.T) { +func TestZoomLineageSpansZoomViewportAndAlignsAtGutter(t *testing.T) { m := newZoomModel() targetPath := "root" + pathSeparator + "A" targetIdx := mustFrameIndex(t, m.frames, targetPath) @@ -289,14 +289,77 @@ func TestZoomKeepsNarrowLineageRail(t *testing.T) { rootIdx := mustFrameIndex(t, m.frames, "root") zoomIdx := mustFrameIndex(t, m.frames, targetPath) - if m.frames[rootIdx].Width != expectedRailWidth { - t.Fatalf("expected root lineage width %d, got %d", expectedRailWidth, m.frames[rootIdx].Width) + _, gutter, ok := m.zoomLineageGeometry(expectedRailWidth) + if !ok { + t.Fatalf("expected lineage geometry to be enabled for zoomed view") + } + expectedLineageWidth := m.width - gutter + if m.frames[rootIdx].Width != expectedLineageWidth { + t.Fatalf("expected root lineage width %d, got %d", expectedLineageWidth, m.frames[rootIdx].Width) + } + if m.frames[zoomIdx].Width != expectedLineageWidth { + t.Fatalf("expected zoom lineage width %d, got %d", expectedLineageWidth, m.frames[zoomIdx].Width) + } + if m.frames[rootIdx].Col != gutter || m.frames[zoomIdx].Col != gutter { + t.Fatalf("expected lineage rail at column %d, got root=%d zoom=%d", gutter, m.frames[rootIdx].Col, m.frames[zoomIdx].Col) + } +} + +func TestZoomLineageKeepsAllFramesWithinViewportWidth(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + + for _, frame := range m.frames { + if frame.Col+frame.Width > m.width { + t.Fatalf("frame exceeds viewport width %d: %+v", m.width, frame) + } + } +} + +func TestZoomLineageAlignsWithZoomedSubtreeColumn(t *testing.T) { + m := newZoomModel() + rootPath := "root" + pathSeparator + "A" + childPath := rootPath + pathSeparator + "A1" + m.selectedIdx = mustFrameIndex(t, m.frames, rootPath) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + + rootIdx := mustFrameIndex(t, m.frames, rootPath) + childIdx := mustFrameIndex(t, m.frames, childPath) + _, gutter, ok := m.zoomLineageGeometry(m.zoomLineWidth) + if !ok { + t.Fatalf("expected lineage geometry to be enabled") + } + if got := m.frames[rootIdx].Col; got != gutter { + t.Fatalf("expected zoom lineage root column %d, got %d", gutter, got) } - if m.frames[zoomIdx].Width != expectedRailWidth { - t.Fatalf("expected zoom lineage width %d, got %d", expectedRailWidth, m.frames[zoomIdx].Width) + if got := m.frames[childIdx].Col; got != gutter { + t.Fatalf("expected first child column to align at %d, got %d", gutter, got) } - if m.frames[rootIdx].Col != 0 || m.frames[zoomIdx].Col != 0 { - t.Fatalf("expected lineage rail at column 0, got root=%d zoom=%d", m.frames[rootIdx].Col, m.frames[zoomIdx].Col) +} + +func TestZoomLineageParentsAreNeverNarrowerThanChildren(t *testing.T) { + m := newZoomModel() + rootPath := "root" + pathSeparator + "A" + m.selectedIdx = mustFrameIndex(t, m.frames, rootPath) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m = settleFlameAnimation(t, m) + + for _, frame := range m.frames { + parentPath := parentFramePath(frame.Path) + if parentPath == "" { + continue + } + parentIdx := m.frameIndexByPath(parentPath) + if parentIdx < 0 { + continue + } + parent := m.frames[parentIdx] + if parent.Width < frame.Width { + t.Fatalf("expected parent %q width %d >= child %q width %d", parent.Path, parent.Width, frame.Path, frame.Width) + } } } @@ -587,32 +650,19 @@ func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) { } } -func TestZoomTransitionAnimatesToNewLayout(t *testing.T) { +func TestZoomTransitionAppliesNewLayoutImmediately(t *testing.T) { m := newZoomModel() pathA := "root" + pathSeparator + "A" - preWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width m.selectedIdx = mustFrameIndex(t, m.frames, pathA) m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) - if !m.animating { - t.Fatalf("expected zoom-in to start animation") + if m.animating { + t.Fatalf("expected zoom-in layout update to apply immediately") } currentWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width targetWidth := m.targetFrames[mustFrameIndex(t, m.targetFrames, pathA)].Width - if currentWidth == targetWidth { - t.Fatalf("expected intermediate zoom frame width to differ from target (current=%d target=%d, pre=%d)", currentWidth, targetWidth, preWidth) - } - - for i := 0; i < 180 && m.animating; i++ { - next, _ := m.Update(animTickMsg{}) - m = next.(Model) - } - if m.animating { - t.Fatalf("expected zoom animation to settle within 180 ticks") - } - finalWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width - if finalWidth != targetWidth { - t.Fatalf("expected final zoom width %d, got %d", targetWidth, finalWidth) + if currentWidth != targetWidth { + t.Fatalf("expected zoom width %d after immediate layout update, got %d", targetWidth, currentWidth) } } @@ -1067,6 +1117,14 @@ func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int { return -1 } +func parentFramePath(path string) string { + lastSep := strings.LastIndex(path, pathSeparator) + if lastSep <= 0 { + return "" + } + return path[:lastSep] +} + func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { t.Helper() next, _ := m.Update(keyMsg) |
