diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-12 22:30:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-12 22:30:32 +0300 |
| commit | 235eb7541d6396e860b23aad63ed44c734cdf767 (patch) | |
| tree | 274d163f00605433174fa89ece82a28d4978c991 /internal/tui/flamegraph/renderer.go | |
| parent | 8a4cb57703845c1d8ffbc9318a4125818a72a545 (diff) | |
refactor flamegraph TUI model into focused sub-controllers
Break the god-class Model in internal/tui/flamegraph/model.go into four
focused sub-controllers that each own a single concern:
- ZoomNavigator : zoom path, stack, root node, and reset/undo logic
- SelectionManager: selected frame index, clamp, traversal, navigation
- FrameAnimator : spring animation, frame layout swap, ancestry index
- SearchController: search query, match indices, filter-visible set
All four are embedded in Model so existing field access (m.selectedIdx,
m.zoomPath, etc.) is promoted unchanged, keeping tests and callers intact.
Model methods now delegate to the sub-controllers rather than holding all
logic inline.
Also split RenderTerminalView (88 lines) into computeRenderParams,
buildToolbar, buildFilteredStatus, and buildNormalStatus helpers, and
extracted buildSnapshotMsg from RefreshFromLiveTrieCmd.
All functions are now ≤ 50 lines. All tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui/flamegraph/renderer.go')
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 156 |
1 files changed, 98 insertions, 58 deletions
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 12e5f8e..8f0d09b 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -206,7 +206,96 @@ func semanticFrameColor(name string) (color.Color, bool) { } } +// renderViewParams bundles the pre-computed layout parameters used by +// RenderTerminalView helpers to avoid threading many individual arguments. +type renderViewParams struct { + rowOffset int + maxRow int + barHeight int + availableRows int + visibleFrames int + truncated bool +} + +// computeRenderParams derives the row-layout parameters for a given frame set +// and viewport height. +func computeRenderParams(frames []tuiFrame, height int) renderViewParams { + availableRows := height - 2 // toolbar + frame-status line + maxRow := maxFrameRowForSet(frames, nil) + totalDepthRows := maxRow + 1 + barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } + rowOffset := 0 + truncated := false + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows + truncated = true + } + return renderViewParams{ + rowOffset: rowOffset, + maxRow: maxRow, + barHeight: barHeight, + availableRows: availableRows, + visibleFrames: countVisibleFrames(frames, nil), + truncated: truncated, + } +} + +// buildToolbar assembles the top-of-view toolbar string and pads/trims it to +// width. The toolbar is replaced by the caller via replaceHeaderLine. +func buildToolbar(frames []tuiFrame, width int, params renderViewParams) string { + viewPath := compactFramePath(frames[0].Path) + toolbar := fmt.Sprintf("Flame | view:%s | frames:%d | rows:%d", + viewPath, params.visibleFrames, params.availableRows) + if params.truncated { + toolbar += " | showing deepest levels" + } + return padOrTrim(toolbar, width) +} + +// buildFilteredStatus builds the per-selection status line when a search filter +// is active. The searchQuery is embedded in the status so the user can see +// which pattern is applied. +func buildFilteredStatus(frames []tuiFrame, selected tuiFrame, selectedIdx int, matchSet map[int]bool, metricLabel, searchQuery string, globalTotal uint64, visibleFrames int) string { + filterCoveredTotal, filterBaseTotal := filterCoverageTotals(frames, matchSet, globalTotal) + filterSystemShare := percentOfTotal(filterCoveredTotal, filterBaseTotal) + selectedFilterShare := 0.0 + if filterCoveredTotal > 0 { + selectedMatchTotal := filterCoverageTotalForPath(frames, matchSet, selected.Path) + selectedFilterShare = percentOfTotal(selectedMatchTotal, filterCoveredTotal) + } + matches := orderedMatchIndices(matchSet) + pos := 0 + if len(matches) > 0 { + if idx := indexOf(matches, selectedIdx); idx >= 0 { + pos = idx + 1 + } + } + frameCoverage := 0.0 + if len(frames) > 0 { + frameCoverage = 100 * float64(visibleFrames) / float64(len(frames)) + } + return fmt.Sprintf("Filter %q: %.1f%% %s (%d/%d matches, %.1f%% frames shown) | Selected: %s total(%s)=%d depth=%d %.2f%% filtered %s", + searchQuery, filterSystemShare, metricLabel, pos, len(matches), frameCoverage, + selected.Name, metricLabel, selected.Total, selected.Depth, selectedFilterShare, metricLabel) +} + +// buildNormalStatus builds the per-selection status line when no filter is active. +func buildNormalStatus(selected tuiFrame, metricLabel string, globalTotal uint64) string { + selectedSystemShare := selected.Percent + if globalTotal > 0 { + selectedSystemShare = percentOfTotal(selected.Total, globalTotal) + } + return fmt.Sprintf("Selected: %s [%s] total(%s)=%d depth=%d col=%d width=%d share=%.2f%% %s", + selected.Name, compactFramePath(selected.Path), metricLabel, selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare, metricLabel) +} + // RenderTerminalView renders a terminal flamegraph viewport from laid out frames. +// The function is split into helpers (computeRenderParams, buildToolbar, +// buildFilteredStatus, buildNormalStatus) to keep each piece under 50 lines. func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet, filterSet map[int]bool, globalTotal uint64, metricLabel string, isDark, searchActive bool, searchQuery string) string { if width < minFlameWidth { return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") @@ -220,9 +309,8 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr if strings.TrimSpace(metricLabel) == "" { metricLabel = "events" } - - filterActive := strings.TrimSpace(searchQuery) != "" - if filterActive { + filterIsActive := strings.TrimSpace(searchQuery) != "" + if filterIsActive { if filterSet == nil { filterSet = computeFilterVisibleSetInto(frames, matchSet, nil) } @@ -232,68 +320,20 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr } 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 := maxFrameRowForSet(frames, nil) - totalDepthRows := maxRow + 1 - barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight) - visibleDepthRows := availableRows / barHeight - if visibleDepthRows < 1 { - visibleDepthRows = 1 - } - rowOffset := 0 - truncated := false - if maxRow+1 > visibleDepthRows { - rowOffset = maxRow + 1 - visibleDepthRows - truncated = true - } - - visibleFrames := countVisibleFrames(frames, nil) - toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames) - toolbar += fmt.Sprintf(" | rows:%d", availableRows) - if truncated { - toolbar += " | showing deepest levels" - } - toolbar = padOrTrim(toolbar, width) - selectedSystemShare := selected.Percent - if globalTotal > 0 { - selectedSystemShare = percentOfTotal(selected.Total, globalTotal) - } - if filterActive { - filterCoveredTotal, filterBaseTotal := filterCoverageTotals(frames, matchSet, globalTotal) - filterSystemShare := percentOfTotal(filterCoveredTotal, filterBaseTotal) - selectedFilterShare := 0.0 - if filterCoveredTotal > 0 { - selectedMatchTotal := filterCoverageTotalForPath(frames, matchSet, selected.Path) - selectedFilterShare = percentOfTotal(selectedMatchTotal, filterCoveredTotal) - } - matches := orderedMatchIndices(matchSet) - pos := 0 - if len(matches) > 0 { - if idx := indexOf(matches, selectedIdx); idx >= 0 { - pos = idx + 1 - } - } - frameCoverage := 0.0 - if len(frames) > 0 { - frameCoverage = 100 * float64(visibleFrames) / float64(len(frames)) - } - status := fmt.Sprintf("Filter %q: %.1f%% %s (%d/%d matches, %.1f%% frames shown) | Selected: %s total(%s)=%d depth=%d %.2f%% filtered %s", - searchQuery, filterSystemShare, metricLabel, pos, len(matches), frameCoverage, - selected.Name, metricLabel, selected.Total, selected.Depth, selectedFilterShare, metricLabel) - return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + params := computeRenderParams(frames, height) + toolbar := buildToolbar(frames, width, params) + var status string + if filterIsActive { + status = buildFilteredStatus(frames, selected, selectedIdx, matchSet, metricLabel, searchQuery, globalTotal, params.visibleFrames) } else { - status := fmt.Sprintf("Selected: %s [%s] total(%s)=%d depth=%d col=%d width=%d share=%.2f%% %s", - selected.Name, compactFramePath(selected.Path), metricLabel, selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare, metricLabel) - return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + status = buildNormalStatus(selected, metricLabel, globalTotal) } + return renderViewRows(toolbar, status, rowsForRender(frames, width, params.rowOffset, params.maxRow, params.barHeight, params.availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterIsActive), width) } func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { |
