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 | |
| parent | 013e46d7856a604d4890a880b8bbfb4b8c58202b (diff) | |
fix(tui): restore bubble modes and stabilize flame zoom lineage
| -rw-r--r-- | internal/tui/dashboard/model.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 12 | ||||
| -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 | ||||
| -rw-r--r-- | internal/tui/tui.go | 50 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 82 |
7 files changed, 277 insertions, 43 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index fb509b0..5949755 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -728,12 +728,12 @@ func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) { func (m Model) allowedVizModes(tab Tab) []tabVizMode { switch tab { case TabSyscalls: - return []tabVizMode{tabVizModeTable, tabVizModeTreemap} + return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap} case TabProcesses: - return []tabVizMode{tabVizModeTable, tabVizModeTreemap} + return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap} case TabFiles: if m.filesDirGrouped { - return []tabVizMode{tabVizModeTable, tabVizModeTreemap} + return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap} } return []tabVizMode{tabVizModeTable} default: diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 2e1ca17..8b03b2b 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -353,6 +353,12 @@ func TestVisualizationCycleForSyscallsTab(t *testing.T) { next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) model := next.(Model) + if got := model.syscallsVizMode; got != tabVizModeBubbles { + t.Fatalf("expected syscalls bubbles mode enabled") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model = next.(Model) if got := model.syscallsVizMode; got != tabVizModeTreemap { t.Fatalf("expected syscalls treemap mode enabled") } @@ -419,6 +425,12 @@ func TestFilesTreemapRequiresDirectoryMode(t *testing.T) { next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) model = next.(Model) + if got := model.filesVizMode; got != tabVizModeBubbles { + t.Fatalf("expected files bubbles mode enabled in directory mode") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model = next.(Model) if got := model.filesVizMode; got != tabVizModeTreemap { t.Fatalf("expected files treemap mode enabled in directory mode") } 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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index deb6150..c375057 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -230,6 +230,7 @@ type Model struct { pidFilter int tidFilter int + pickerReturn *pickerReturnState exportEnabled bool isDark bool focused bool @@ -247,6 +248,11 @@ type Model struct { suppressPressUntil time.Time } +type pickerReturnState struct { + pidFilter int + tidFilter int +} + // NewModel creates the top-level TUI model. func NewModel(initialPID int, startTrace TraceStarter) Model { return NewModelWithConfig(flags.Get(), initialPID, startTrace) @@ -429,6 +435,12 @@ func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool { !m.dashboard.BlocksGlobalShortcuts(msg) } +func (m Model) shouldCancelPickerToDashboard(msg tea.KeyPressMsg) bool { + return m.screen == ScreenPIDPicker && + m.pickerReturn != nil && + (isEscKey(msg) || key.Matches(msg, m.keys.Quit)) +} + func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool { if m.helpOverlayVisible { return false @@ -444,6 +456,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo } return m, nil, true } + if m.shouldCancelPickerToDashboard(msg) { + next, cmd := m.cancelPickerToDashboard() + return next, cmd, true + } if key.Matches(msg, m.keys.Quit) { if m.canQuitFromMainDashboard(msg) { m.quitting = true @@ -638,6 +654,7 @@ func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) { m.stopTrace() m.pidFilter = pid m.tidFilter = -1 + m.pickerReturn = nil m.dashboard.SetPidFilter(pid) m.screen = ScreenDashboard m.attaching = true @@ -654,6 +671,7 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { m.stopTrace() m.pidFilter = pid m.tidFilter = tid + m.pickerReturn = nil m.dashboard.SetPidFilter(pid) m.screen = ScreenDashboard m.attaching = true @@ -662,6 +680,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { } func (m Model) reselectPID() (tea.Model, tea.Cmd) { + m.pickerReturn = &pickerReturnState{ + pidFilter: m.pidFilter, + tidFilter: m.tidFilter, + } m.stopTrace() m.screen = ScreenPIDPicker m.attaching = false @@ -683,6 +705,10 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { func (m Model) reselectTID() (tea.Model, tea.Cmd) { pid := m.pidFilter + m.pickerReturn = &pickerReturnState{ + pidFilter: m.pidFilter, + tidFilter: m.tidFilter, + } m.stopTrace() m.screen = ScreenPIDPicker m.attaching = false @@ -708,6 +734,22 @@ func selectedPIDFilter(pid int) int { return pid } +func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) { + if m.pickerReturn == nil { + return m, nil + } + returnState := *m.pickerReturn + m.pickerReturn = nil + m.stopTrace() + m.pidFilter = returnState.pidFilter + m.tidFilter = returnState.tidFilter + m.dashboard.SetPidFilter(m.pidFilter) + m.screen = ScreenDashboard + m.attaching = true + m.lastErr = nil + return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) +} + func (m *Model) beginTraceCmd() tea.Cmd { ctx, cancel := context.WithCancel(context.Background()) m.traceStop = cancel @@ -810,8 +852,12 @@ func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool { return msg.String() == "H" } +func isEscKey(msg tea.KeyPressMsg) bool { + return msg.Code == tea.KeyEsc || msg.String() == "esc" +} + func isHelpOverlayCloseKey(msg tea.KeyPressMsg) bool { - return msg.Code == tea.KeyEsc || msg.String() == "esc" || msg.String() == "?" + return isEscKey(msg) || msg.String() == "?" } func isHelpOverlayQuitKey(msg tea.KeyPressMsg) bool { @@ -930,7 +976,7 @@ func (m Model) helpSections() []helpSection { { title: "PID/TID Picker", lines: []string{ - "enter select r refresh esc back", + "enter select r refresh esc/q back", }, }, } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e4bdbff..82fe9da 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -209,6 +209,88 @@ func TestQuitKeyDoesNotExitOnPIDPickerScreen(t *testing.T) { } } +func TestQuitKeyOnReselectPIDPickerReturnsToDashboardLikeEsc(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + m.pidFilter = 1111 + m.tidFilter = 2222 + m.dashboard.SetPidFilter(1111) + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + m = next.(Model) + + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + m = next.(Model) + if m.screen != ScreenPIDPicker { + t.Fatalf("expected pid picker screen after reselect, got %v", m.screen) + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})}) + updated := next.(Model) + if cmd == nil { + t.Fatalf("expected q in reselect picker to return to dashboard and restart tracing") + } + if updated.screen != ScreenDashboard { + t.Fatalf("expected dashboard screen after q cancel, got %v", updated.screen) + } + if !updated.attaching { + t.Fatalf("expected attaching=true after q cancel") + } + if updated.quitting { + t.Fatalf("expected q in reselect picker to behave like esc, not quit") + } + if updated.pickerReturn != nil { + t.Fatalf("expected picker return context to clear after cancel") + } + if updated.pidFilter != 1111 || updated.tidFilter != 2222 { + t.Fatalf("expected previous pid/tid filters restored, got pid=%d tid=%d", updated.pidFilter, updated.tidFilter) + } +} + +func TestEscOnReselectPIDPickerReturnsToDashboard(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + m.pidFilter = 3333 + m.tidFilter = 4444 + m.dashboard.SetPidFilter(3333) + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})}) + m = next.(Model) + + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})}) + m = next.(Model) + if m.screen != ScreenPIDPicker { + t.Fatalf("expected pid picker screen after reselect, got %v", m.screen) + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + updated := next.(Model) + if cmd == nil { + t.Fatalf("expected esc in reselect picker to return to dashboard and restart tracing") + } + if updated.screen != ScreenDashboard { + t.Fatalf("expected dashboard screen after esc cancel, got %v", updated.screen) + } + if !updated.attaching { + t.Fatalf("expected attaching=true after esc cancel") + } + if updated.quitting { + t.Fatalf("expected esc in reselect picker not to quit app") + } + if updated.pickerReturn != nil { + t.Fatalf("expected picker return context to clear after cancel") + } + if updated.pidFilter != 3333 || updated.tidFilter != 4444 { + t.Fatalf("expected previous pid/tid filters restored, got pid=%d tid=%d", updated.pidFilter, updated.tidFilter) + } +} + func TestQuitKeyClosesProbeModalLikeEsc(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard |
