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/selection_manager.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/selection_manager.go')
| -rw-r--r-- | internal/tui/flamegraph/selection_manager.go | 401 |
1 files changed, 401 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/selection_manager.go b/internal/tui/flamegraph/selection_manager.go new file mode 100644 index 0000000..2a691a5 --- /dev/null +++ b/internal/tui/flamegraph/selection_manager.go @@ -0,0 +1,401 @@ +package flamegraph + +import ( + "cmp" + "slices" + "strings" +) + +// SelectionManager tracks the currently selected frame index and related +// visibility/navigation state for the flamegraph TUI. It depends on the frame +// slice and ancestry index that live on the Model, which are passed in as +// arguments to keep the sub-controller self-contained. +type SelectionManager struct { + selectedIdx int + subtreeSet map[int]bool + hasNavigableSnapshot bool +} + +// newSelectionManager constructs a SelectionManager with default state. +func newSelectionManager() SelectionManager { + return SelectionManager{ + subtreeSet: make(map[int]bool), + } +} + +// clamp ensures selectedIdx is within [0, len(frames)-1]. +func (s *SelectionManager) clamp(frames []tuiFrame) { + if len(frames) == 0 { + s.selectedIdx = 0 + return + } + if s.selectedIdx < 0 { + s.selectedIdx = 0 + } + if s.selectedIdx >= len(frames) { + s.selectedIdx = len(frames) - 1 + } +} + +// filterActive reports whether a search filter is currently applied. +func filterActive(searchQuery string) bool { + return strings.TrimSpace(searchQuery) != "" +} + +// navigableSet returns the set of frame indices visible under the current filter, +// or nil when no filter is active (meaning all frames are navigable). +func navigableSet(searchQuery string, filterVisible map[int]bool) map[int]bool { + if !filterActive(searchQuery) { + return nil + } + return filterVisible +} + +// frameNavigable reports whether frame idx can be selected given an optional +// filter-visible set. +func frameNavigable(idx int, frames []tuiFrame, searchQuery string, filterVisible map[int]bool) bool { + if idx < 0 || idx >= len(frames) { + return false + } + if !filterActive(searchQuery) { + return true + } + return filterVisible[idx] +} + +// ensureNavigable moves selectedIdx to the first navigable frame when the +// current selection is hidden by a filter. +func (s *SelectionManager) ensureNavigable(frames []tuiFrame, matchIndices map[int]bool, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + s.selectedIdx = 0 + return + } + s.clamp(frames) + if frameNavigable(s.selectedIdx, frames, searchQuery, filterVisible) { + return + } + // Prefer any existing match index. + for _, idx := range orderedMatchIndices(matchIndices) { + if frameNavigable(idx, frames, searchQuery, filterVisible) { + s.selectedIdx = idx + return + } + } + // Fall back to the first navigable frame. + for idx := range frames { + if frameNavigable(idx, frames, searchQuery, filterVisible) { + s.selectedIdx = idx + return + } + } +} + +// ensureVisible scrolls selectedIdx to a frame that is actually rendered when +// the layout is taller than the viewport. Visibility is determined by the row +// offset computed from the full frame set. +func (s *SelectionManager) ensureVisible(frames []tuiFrame, height int, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + return + } + s.clamp(frames) + s.ensureNavigable(frames, nil, searchQuery, filterVisible) + if !frameNavigable(s.selectedIdx, frames, searchQuery, filterVisible) { + return + } + rowOffset := visibleRowOffset(frames, height, searchQuery, filterVisible) + selected := frames[s.selectedIdx] + if selected.Row >= rowOffset { + return + } + bestIdx := -1 + bestScore := int(^uint(0) >> 1) + for idx, frame := range frames { + if !frameNavigable(idx, frames, searchQuery, filterVisible) { + continue + } + if frame.Row < rowOffset { + continue + } + score := abs(frame.Row-rowOffset)*1000 + abs(frame.Col-selected.Col) + if score < bestScore { + bestIdx = idx + bestScore = score + } + } + if bestIdx >= 0 { + s.selectedIdx = bestIdx + } +} + +// restoreByPath tries to set selectedIdx to the frame with the given path. +// Falls back to a boundary-prefix match if the exact path is gone. +func (s *SelectionManager) restoreByPath(frames []tuiFrame, path string) { + if path == "" || len(frames) == 0 { + return + } + for idx, frame := range frames { + if frame.Path == path { + s.selectedIdx = idx + return + } + } + for idx, frame := range frames { + if hasPathBoundaryPrefix(path, frame.Path) || hasPathBoundaryPrefix(frame.Path, path) { + s.selectedIdx = idx + return + } + } +} + +// moveVertical moves the selection one depth level up or down within the frame set. +// Picks the horizontally closest frame at the target depth. +func (s *SelectionManager) moveVertical(frames []tuiFrame, delta int, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + return + } + s.clamp(frames) + s.ensureNavigable(frames, nil, searchQuery, filterVisible) + current := frames[s.selectedIdx] + targets := framesAtDepthFiltered(frames, current.Depth+delta, navigableSet(searchQuery, filterVisible)) + if len(targets) == 0 { + return + } + best := targets[0] + bestDist := abs(frames[best].Col - current.Col) + for _, idx := range targets[1:] { + dist := abs(frames[idx].Col - current.Col) + if dist < bestDist { + best = idx + bestDist = dist + } + } + s.selectedIdx = best +} + +// moveVerticalWithFallback tries primaryDelta, then fallbackDelta, then +// traversal order when the selection does not change. +func (s *SelectionManager) moveVerticalWithFallback(frames []tuiFrame, searchQuery string, filterVisible map[int]bool, primaryDelta, fallbackDelta, traversalDelta int) { + before := s.selectedIdx + s.moveVertical(frames, primaryDelta, searchQuery, filterVisible) + if s.selectedIdx == before && fallbackDelta != 0 { + s.moveVertical(frames, fallbackDelta, searchQuery, filterVisible) + } + if s.selectedIdx == before && traversalDelta != 0 { + s.moveTraversal(frames, traversalDelta, searchQuery, filterVisible) + } +} + +// moveSibling navigates to the previous or next sibling at the same depth. +// Falls back to traversal order when there is only one sibling. +func (s *SelectionManager) moveSibling(frames []tuiFrame, delta int, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + return + } + before := s.selectedIdx + s.clamp(frames) + s.ensureNavigable(frames, nil, searchQuery, filterVisible) + current := frames[s.selectedIdx] + siblings := framesAtDepthFiltered(frames, current.Depth, navigableSet(searchQuery, filterVisible)) + if len(siblings) <= 1 { + s.moveTraversal(frames, delta, searchQuery, filterVisible) + return + } + pos := indexOf(siblings, s.selectedIdx) + if pos < 0 { + s.moveTraversal(frames, delta, searchQuery, filterVisible) + return + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(siblings) { + next = len(siblings) - 1 + } + s.selectedIdx = siblings[next] + if s.selectedIdx == before { + s.moveTraversal(frames, delta, searchQuery, filterVisible) + } +} + +// jumpToTop moves the selection to the deepest frame closest to the current +// horizontal column. +func (s *SelectionManager) jumpToTop(frames []tuiFrame, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + return + } + s.clamp(frames) + s.ensureNavigable(frames, nil, searchQuery, filterVisible) + include := navigableSet(searchQuery, filterVisible) + currentCol := frames[s.selectedIdx].Col + bestIdx := -1 + bestDepth := -1 + bestDist := int(^uint(0) >> 1) + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } + dist := abs(frame.Col - currentCol) + if frame.Depth > bestDepth { + bestDepth = frame.Depth + bestIdx = idx + bestDist = dist + continue + } + if frame.Depth == bestDepth { + if dist < bestDist || (dist == bestDist && frame.Col < frames[bestIdx].Col) { + bestIdx = idx + bestDist = dist + } + } + } + if bestIdx >= 0 { + s.selectedIdx = bestIdx + } +} + +// jumpToRoot moves the selection to the shallowest frame closest to the current +// horizontal column. Prefers the zoom root path when available. +func (s *SelectionManager) jumpToRoot(frames []tuiFrame, rootPath string, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 { + return + } + s.clamp(frames) + s.ensureNavigable(frames, nil, searchQuery, filterVisible) + if rootPath != "" { + for idx, frame := range frames { + if frame.Path == rootPath && (s.selectedIdx == idx || !filterActive(searchQuery) || filterVisible[idx]) { + s.selectedIdx = idx + return + } + } + } + include := navigableSet(searchQuery, filterVisible) + currentCol := frames[s.selectedIdx].Col + bestIdx := -1 + bestDepth := int(^uint(0) >> 1) + bestDist := int(^uint(0) >> 1) + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } + dist := abs(frame.Col - currentCol) + if frame.Depth < bestDepth { + bestDepth = frame.Depth + bestDist = dist + bestIdx = idx + continue + } + if frame.Depth == bestDepth { + if dist < bestDist || (dist == bestDist && frame.Col < frames[bestIdx].Col) { + bestDist = dist + bestIdx = idx + } + } + } + if bestIdx >= 0 { + s.selectedIdx = bestIdx + } +} + +// moveTraversal navigates through frames in depth-then-column order. +func (s *SelectionManager) moveTraversal(frames []tuiFrame, delta int, searchQuery string, filterVisible map[int]bool) { + if len(frames) == 0 || delta == 0 { + return + } + order := visibleTraversalOrder(frames, searchQuery, filterVisible) + if len(order) == 0 { + return + } + pos := indexOf(order, s.selectedIdx) + if pos < 0 { + pos = 0 + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(order) { + next = len(order) - 1 + } + s.selectedIdx = order[next] +} + +// visibleTraversalOrder returns frame indices sorted by depth then column. +func visibleTraversalOrder(frames []tuiFrame, searchQuery string, filterVisible map[int]bool) []int { + include := navigableSet(searchQuery, filterVisible) + indices := make([]int, 0, len(frames)) + for idx := range frames { + if include != nil && !include[idx] { + continue + } + indices = append(indices, idx) + } + slices.SortFunc(indices, func(a, b int) int { + left := frames[a] + right := frames[b] + if left.Depth != right.Depth { + return cmp.Compare(left.Depth, right.Depth) + } + if left.Col != right.Col { + return cmp.Compare(left.Col, right.Col) + } + if left.Row != right.Row { + return cmp.Compare(left.Row, right.Row) + } + return cmp.Compare(a, b) + }) + return indices +} + +// visibleRowOffset computes the first logical row that fits within the visible +// area, accounting for toolbar and status lines. +func visibleRowOffset(frames []tuiFrame, height int, searchQuery string, filterVisible map[int]bool) int { + if len(frames) == 0 { + return 0 + } + availableRows := height - 2 // toolbar + status + if availableRows <= 0 { + return 0 + } + maxRow := maxFrameRowForSet(frames, navigableSet(searchQuery, filterVisible)) + if maxRow+1 <= availableRows { + return 0 + } + return maxRow + 1 - availableRows +} + +// framesAtDepth returns all frame indices at a given depth, respecting the +// optional filter-visible set. Sorted by column. +func framesAtDepth(frames []tuiFrame, depth int) []int { + return framesAtDepthFiltered(frames, depth, nil) +} + +func framesAtDepthFiltered(frames []tuiFrame, depth int, include map[int]bool) []int { + if depth < 0 { + return nil + } + indices := make([]int, 0) + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } + if frame.Depth == depth { + indices = append(indices, idx) + } + } + slices.SortFunc(indices, func(a, b int) int { + return cmp.Compare(frames[a].Col, frames[b].Col) + }) + return indices +} + +// indexOf returns the position of target in values, or -1 if not found. +func indexOf(values []int, target int) int { + for idx, value := range values { + if value == target { + return idx + } + } + return -1 +} |
