diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 09:05:51 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 09:05:51 +0200 |
| commit | 10c5d48413afaef88626419d8c4bf9fbf6f1c902 (patch) | |
| tree | fe6c86eaf16c16070aa8025e207e5d88bd5595c6 /internal/tui/flamegraph/renderer.go | |
| parent | 0a69582e7f8111c2a508d8f062de91a06f296974 (diff) | |
Fix flamegraph navigation, filtering, and system-share feedback
Diffstat (limited to 'internal/tui/flamegraph/renderer.go')
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 204 |
1 files changed, 182 insertions, 22 deletions
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 0c18d5c..517929e 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -191,7 +191,7 @@ func semanticFrameColor(name string) (color.Color, bool) { } // RenderTerminalView renders a terminal flamegraph viewport from laid out frames. -func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet map[int]bool, isDark, searchActive bool, searchQuery string) string { +func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, isDark, searchActive bool, searchQuery string) string { if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") } @@ -202,8 +202,27 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr return common.PanelStyle.Render("Flame: waiting for data...") } + filterActive := strings.TrimSpace(searchQuery) != "" + if filterActive { + if filterSet == nil { + filterSet = computeFilterVisibleSetInto(frames, matchSet, nil) + } + if len(filterSet) == 0 { + return common.PanelStyle.Render(fmt.Sprintf("Flame: no frames match filter %q", searchQuery)) + } + } else { + filterSet = nil + } + + selectedIdx = normalizeSelectedIndex(frames, selectedIdx, filterSet) + selected := frames[selectedIdx] + viewPath := compactFramePath(frames[0].Path) + if subtreeSet == nil { + subtreeSet = computeSubtreeSet(frames, selectedIdx) + } + availableRows := height - 2 // toolbar + status - maxRow := maxFrameRow(frames) + maxRow := maxFrameRowForSet(frames, filterSet) rowOffset := 0 truncated := false if maxRow+1 > availableRows { @@ -211,22 +230,20 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr truncated = true } - if selectedIdx < 0 || selectedIdx >= len(frames) { - selectedIdx = 0 - } - selected := frames[selectedIdx] - viewPath := compactFramePath(frames[0].Path) - filterActive := strings.TrimSpace(searchQuery) != "" - if subtreeSet == nil { - subtreeSet = computeSubtreeSet(frames, selectedIdx) + visibleFrames := countVisibleFrames(frames, filterSet) + toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames) + if filterActive { + toolbar += fmt.Sprintf("/%d", len(frames)) } - - toolbar := fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", viewPath, len(frames), availableRows) + toolbar += fmt.Sprintf(" | rows:%d", availableRows) if truncated { toolbar += " | showing deepest levels" } toolbar = padOrTrim(toolbar, width) - status := fmt.Sprintf("Selected: %s [%s] total=%d depth=%d", selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth) + selectedSystemShare := selected.Percent + if globalTotal > 0 { + selectedSystemShare = percentOfTotal(selected.Total, globalTotal) + } if filterActive { matches := orderedMatchIndices(matchSet) pos := 0 @@ -235,18 +252,28 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr pos = idx + 1 } } - coverage := 0.0 + frameCoverage := 0.0 if len(frames) > 0 { - coverage = 100 * float64(len(matches)) / float64(len(frames)) + frameCoverage = 100 * float64(visibleFrames) / float64(len(frames)) } - status += fmt.Sprintf(" | Filter %q %d/%d (%.1f%%)", searchQuery, pos, len(matches), coverage) + filterSystemShare := filterSampleCoverage(frames, matchSet, globalTotal) + status := fmt.Sprintf("Filter %q: %.1f%% system (%d/%d matches, %d visible, %.1f%% frames) | Selected: %s total=%d depth=%d %.2f%% system", + searchQuery, filterSystemShare, pos, len(matches), visibleFrames, frameCoverage, + selected.Name, selected.Total, selected.Depth, selectedSystemShare) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive), width) } else { - status += fmt.Sprintf(" %.2f%%", selected.Percent) + status := fmt.Sprintf("Selected: %s [%s] total=%d depth=%d col=%d width=%d share=%.2f%%", + selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive), width) } - status = padOrTrim(status, width) +} - rows := buildRenderRows(frames, width, rowOffset, maxRow, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive) +func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet, filterSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { + return buildRenderRows(frames, width, rowOffset, maxRow, selectedPath, subtreeSet, matchSet, filterSet, selectedIdx, isDark, searchActive, filterActive) +} +func renderViewRows(toolbar, status string, rows []string, width int) string { + status = padOrTrim(status, width) var b strings.Builder b.Grow((width + 1) * (len(rows) + 2)) b.WriteString(toolbar) @@ -264,9 +291,12 @@ type indexedFrame struct { frame tuiFrame } -func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow int, selectedPath string, subtreeSet, matchSet, filterSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { rowsByDepth := make(map[int][]indexedFrame) for idx, frame := range frames { + if filterSet != nil && !filterSet[idx] { + continue + } if frame.Row < rowOffset || frame.Row > maxRow { continue } @@ -359,6 +389,36 @@ func hasPathBoundaryPrefix(value, prefix string) bool { return value[len(prefix)] == pathSeparatorByte } +func computeFilterVisibleSetInto(frames []tuiFrame, matchSet, visible map[int]bool) map[int]bool { + if visible == nil { + visible = make(map[int]bool) + } else { + for idx := range visible { + delete(visible, idx) + } + } + if len(matchSet) == 0 { + return visible + } + + matchPaths := make([]string, 0, len(matchSet)) + for idx := range matchSet { + if idx >= 0 && idx < len(frames) { + matchPaths = append(matchPaths, frames[idx].Path) + } + } + for idx, frame := range frames { + for _, matchPath := range matchPaths { + // Show matching frames and their full ancestry to root. + if frame.Path == matchPath || hasPathBoundaryPrefix(matchPath, frame.Path) { + visible[idx] = true + break + } + } + } + return visible +} + func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style { _ = searchActive base := lipgloss.NewStyle(). @@ -393,7 +453,10 @@ func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, mat } if filterActive { - return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true) + if frameRelation(frame.Path, selectedPath) == relationAncestor { + return base.BorderLeft(true).BorderForeground(common.ColorAccent) + } + return base.Foreground(common.ColorPrimary) } if inSubtree { @@ -458,8 +521,15 @@ func frameRelation(path, selectedPath string) relation { } func maxFrameRow(frames []tuiFrame) int { + return maxFrameRowForSet(frames, nil) +} + +func maxFrameRowForSet(frames []tuiFrame, include map[int]bool) int { maxRow := 0 - for _, frame := range frames { + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } if frame.Row > maxRow { maxRow = frame.Row } @@ -467,6 +537,96 @@ func maxFrameRow(frames []tuiFrame) int { return maxRow } +func countVisibleFrames(frames []tuiFrame, include map[int]bool) int { + if include == nil { + return len(frames) + } + count := 0 + for idx := range frames { + if include[idx] { + count++ + } + } + return count +} + +func normalizeSelectedIndex(frames []tuiFrame, selectedIdx int, include map[int]bool) int { + if len(frames) == 0 { + return 0 + } + if selectedIdx >= 0 && selectedIdx < len(frames) && (include == nil || include[selectedIdx]) { + return selectedIdx + } + if include != nil { + for idx := range frames { + if include[idx] { + return idx + } + } + } + return 0 +} + +func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) float64 { + if len(frames) == 0 || len(matchSet) == 0 { + return 0 + } + rootTotal := totalBase + if rootTotal == 0 { + rootTotal = frames[0].Total + } + if rootTotal == 0 { + return 0 + } + type matchRoot struct { + path string + total uint64 + } + roots := make([]matchRoot, 0, len(matchSet)) + for idx := range matchSet { + if idx < 0 || idx >= len(frames) { + continue + } + roots = append(roots, matchRoot{ + path: frames[idx].Path, + total: frames[idx].Total, + }) + } + sort.Slice(roots, func(i, j int) bool { + return len(roots[i].path) < len(roots[j].path) + }) + merged := make([]matchRoot, 0, len(roots)) + for _, candidate := range roots { + covered := false + for _, root := range merged { + if candidate.path == root.path || hasPathBoundaryPrefix(candidate.path, root.path) { + covered = true + break + } + } + if covered { + continue + } + merged = append(merged, candidate) + } + var coveredTotal uint64 + for _, root := range merged { + coveredTotal += root.total + } + coverage := 100 * float64(coveredTotal) / float64(rootTotal) + if coverage > 100 { + return 100 + } + return coverage +} + +func percentOfTotal(value, total uint64) float64 { + if total == 0 { + return 0 + } + return 100 * float64(value) / float64(total) +} + func padOrTrim(s string, width int) string { if width <= 0 { return "" |
