summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph/selection_manager.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-12 22:30:32 +0300
committerPaul Buetow <paul@buetow.org>2026-05-12 22:30:32 +0300
commit235eb7541d6396e860b23aad63ed44c734cdf767 (patch)
tree274d163f00605433174fa89ece82a28d4978c991 /internal/tui/flamegraph/selection_manager.go
parent8a4cb57703845c1d8ffbc9318a4125818a72a545 (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.go401
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
+}