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 | |
| 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>
| -rw-r--r-- | internal/tui/flamegraph/frame_animator.go | 170 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 769 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 156 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 124 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search_controller.go | 168 | ||||
| -rw-r--r-- | internal/tui/flamegraph/selection_manager.go | 401 | ||||
| -rw-r--r-- | internal/tui/flamegraph/zoom_navigator.go | 65 |
7 files changed, 1099 insertions, 754 deletions
diff --git a/internal/tui/flamegraph/frame_animator.go b/internal/tui/flamegraph/frame_animator.go new file mode 100644 index 0000000..26755e8 --- /dev/null +++ b/internal/tui/flamegraph/frame_animator.go @@ -0,0 +1,170 @@ +package flamegraph + +import "time" + +// FrameAnimator manages the animated transition between frame layouts. It owns +// the current frame slice, the target frame slice, the frame ancestry index, and +// the spring-based AnimationState. The Model delegates all layout-swap and +// animation-tick logic here. +type FrameAnimator struct { + animation AnimationState + animating bool + frames []tuiFrame + targetFrames []tuiFrame + ancestry frameAncestry +} + +// newFrameAnimator constructs a FrameAnimator with spring parameters suitable +// for the default 30 fps / ω=6 / ζ=1 damping curve. +func newFrameAnimator() FrameAnimator { + return FrameAnimator{ + animation: NewAnimationState(30, 6.0, 1.0), + } +} + +// applyTargetFrames installs a new frame layout and ancestry index. When animate +// is true and a previous layout exists, it kicks off a spring animation from +// the current positions. When animate is false (zoom transitions, user driving), +// it snaps directly to the target. +// +// After swapping frames it restores the selection to prevPath, clamps it, +// recomputes the filter state, ensures visibility, and refreshes the subtree +// highlight — maintaining the same post-swap invariants as the old Model method. +func (fa *FrameAnimator) applyTargetFrames( + targetFrames []tuiFrame, + ancestry frameAncestry, + prevPath string, + animate bool, + sel *SelectionManager, + search *SearchController, + height int, +) { + fa.targetFrames = targetFrames + fa.ancestry = ancestry + fa.animation.SetTargets(fa.targetFrames) + if animate && len(fa.frames) > 0 && !fa.animation.Settled() { + fa.animating = true + fa.frames = fa.animation.CurrentFrames() + } else { + fa.animating = false + fa.frames = append(fa.frames[:0], fa.targetFrames...) + } + if len(fa.frames) > 1 { + sel.hasNavigableSnapshot = true + } + sel.restoreByPath(fa.frames, prevPath) + sel.clamp(fa.frames) + search.recomputeFilterState(fa.frames, fa.ancestry) + sel.ensureNavigable(fa.frames, search.matchIndices, search.searchQuery, search.filterVisible) + sel.ensureVisible(fa.frames, height, search.searchQuery, search.filterVisible) + sel.subtreeSet = subtreeSetUsingAncestry(fa.frames, sel.selectedIdx, fa.ancestry, sel.subtreeSet) +} + +// tickAnimation advances the spring by one frame and updates the current frames. +// Returns true while animation is still active. +func (fa *FrameAnimator) tickAnimation(sel *SelectionManager, search *SearchController) bool { + fa.animating = fa.animation.Tick(0) + fa.frames = fa.animation.CurrentFrames() + sel.clamp(fa.frames) + sel.subtreeSet = subtreeSetUsingAncestry(fa.frames, sel.selectedIdx, fa.ancestry, sel.subtreeSet) + return fa.animating +} + +// reset clears all frame/animation state, preserving the configured spring parameters. +func (fa *FrameAnimator) reset() { + fa.animation = NewAnimationState(30, 6.0, 1.0) + fa.animating = false + fa.frames = nil + fa.targetFrames = nil + fa.ancestry = frameAncestry{} +} + +// frameIndexAt returns the index of the frame rendered at terminal coordinates +// (x, y), or -1 if no frame occupies that cell. showHelp adds one extra line +// to the UI chrome so the frame area row calculations account for it. +func frameIndexAt(frames []tuiFrame, x, y, width, height int, showHelp bool) int { + if len(frames) == 0 || width <= 0 || height <= 0 { + return -1 + } + if x < 0 || x >= width || y < 0 { + return -1 + } + extraLines := 1 // selection status line + if showHelp { + extraLines++ + } + renderHeight := height - extraLines + if renderHeight < 3 { + renderHeight = 3 + } + availableRows := renderHeight - 2 // flame toolbar + frame-status line + if availableRows < 1 { + return -1 + } + // Row 0 is flame toolbar, rows 1..availableRows are bars, last row is status. + if y < 1 || y > availableRows { + return -1 + } + targetRow := frameCoordToTargetRow(frames, y-1, availableRows) + if targetRow < 0 { + return -1 + } + return findFrameAtRow(frames, targetRow, x, width) +} + +// frameCoordToTargetRow converts a data-area row offset (0-based, after +// stripping the toolbar row) into the logical frame row index. Returns -1 when +// the coordinate falls in the top padding above the first visible row. +func frameCoordToTargetRow(frames []tuiFrame, dataRow, availableRows int) int { + maxRow := maxFrameRowForSet(frames, nil) + barHeight := computeBarHeight(availableRows, maxRow+1, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } + rowOffset := 0 + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows + } + renderedRows := (maxRow - rowOffset + 1) * barHeight + padTop := 0 + if renderedRows < availableRows { + padTop = availableRows - renderedRows + } + if dataRow < padTop { + return -1 + } + depthFromTop := (dataRow - padTop) / barHeight + return maxRow - depthFromTop +} + +// findFrameAtRow scans frames for the narrowest one that occupies logical row +// targetRow and contains pixel column x within [0, width). Returning the +// narrowest frame resolves overlap between wide parent and narrow child bars. +func findFrameAtRow(frames []tuiFrame, targetRow, x, width int) int { + best := -1 + bestWidth := int(^uint(0) >> 1) + for idx, frame := range frames { + if frame.Row != targetRow || frame.Col >= width { + continue + } + right := min(width, frame.Col+frame.Width) + if x < frame.Col || x >= right { + continue + } + if frame.Width < bestWidth { + best = idx + bestWidth = frame.Width + } + } + return best +} + +// driveWindowActive reports whether lastKeyAt falls within the active drive +// window where the user is considered to be actively pressing keys. +func driveWindowActive(lastKeyAt time.Time) bool { + if lastKeyAt.IsZero() { + return false + } + return time.Since(lastKeyAt) < driveWindow +} diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 7fbed6c..8d2e577 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -1,7 +1,6 @@ package flamegraph import ( - "cmp" "fmt" "image/color" "slices" @@ -12,7 +11,6 @@ import ( common "ior/internal/tui/common" "charm.land/bubbles/v2/key" - "charm.land/bubbles/v2/textinput" tea "charm.land/bubbletea/v2" ) @@ -122,7 +120,17 @@ func defaultFlameKeyMap() flameKeyMap { } // Model is the Bubble Tea model for the TUI flamegraph tab. +// It delegates zoom, selection, animation, and search concerns to four focused +// sub-controllers: ZoomNavigator, SelectionManager, FrameAnimator, and +// SearchController. The sub-controllers are embedded so existing field names +// (e.g. m.selectedIdx, m.zoomPath) remain accessible directly. type Model struct { + // Sub-controllers — each owns a single concern. + ZoomNavigator // zoom path, stack, and root node management + SelectionManager // selected frame index and subtree highlight + FrameAnimator // animated frame transitions and ancestry index + SearchController // search query, match indices, filter-visible set + liveTrie LiveTrieSource lastVersion uint64 snapshot *snapshotNode @@ -133,24 +141,9 @@ type Model struct { // than one snapshot rebuild concurrently. refreshInFlight bool - frames []tuiFrame - targetFrames []tuiFrame - ancestry frameAncestry - width int - height int + width int + height int - selectedIdx int - zoomStack []zoomState - zoomRoot *snapshotNode - zoomPath string - zoomLineWidth int - - searchActive bool - searchInput textinput.Model - searchQuery string - matchIndices map[int]bool - filterVisible map[int]bool - subtreeSet map[int]bool showHelp bool statusMessage string lastKeyDebug string @@ -159,9 +152,7 @@ type Model struct { fieldIndex int countField string - animation AnimationState - animating bool - paused bool + paused bool // lastKeyAt records when the user most recently pressed a key. While the // user is actively driving the view (lastKeyAt within driveWindow ago), @@ -175,10 +166,8 @@ type Model struct { // caching avoids re-running RenderTerminalView when nothing visible has // moved. Lives behind a pointer so the value-receiver View() can update it. viewCache *flameViewCache - // hasNavigableSnapshot flips once we have at least one selectable non-root frame. - hasNavigableSnapshot bool - isDark bool - keys flameKeyMap + isDark bool + keys flameKeyMap } // tuiFrame stores one terminal flamegraph frame cell. @@ -195,20 +184,17 @@ type tuiFrame struct { } // NewModel constructs a flamegraph tab model with default state. +// The four embedded sub-controllers (ZoomNavigator, SelectionManager, +// FrameAnimator, SearchController) are initialised here; the Model delegates +// their respective concerns to them. func NewModel(liveTrie LiveTrieSource) Model { - searchInput := textinput.New() - searchInput.Prompt = "/" - searchInput.CharLimit = 0 - searchInput.SetWidth(32) - searchInput.SetStyles(textinput.DefaultStyles(true)) - m := Model{ - liveTrie: liveTrie, - matchIndices: make(map[int]bool), - filterVisible: make(map[int]bool), - subtreeSet: make(map[int]bool), - searchInput: searchInput, - viewCache: &flameViewCache{}, + ZoomNavigator: ZoomNavigator{}, + SelectionManager: newSelectionManager(), + FrameAnimator: newFrameAnimator(), + SearchController: newSearchController(true), + liveTrie: liveTrie, + viewCache: &flameViewCache{}, fieldPresets: [][]string{ {"comm", "tracepoint", "path"}, {"path", "tracepoint", "comm"}, @@ -218,7 +204,6 @@ func NewModel(liveTrie LiveTrieSource) Model { }, isDark: true, keys: defaultFlameKeyMap(), - animation: NewAnimationState(30, 6.0, 1.0), countField: "count", } m.syncFieldPresetToTrie() @@ -231,17 +216,18 @@ func (m Model) Init() tea.Cmd { return nil } -// Update handles incoming messages. +// Update handles incoming messages. Delegates animation ticks to FrameAnimator, +// snapshot arrivals to handleSnapshotReady, and key/mouse events to the +// appropriate handler. func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case animTickMsg: if !m.animating { return m, nil } - m.animating = m.animation.Tick(0) - m.frames = m.animation.CurrentFrames() - m.clampSelection() - m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) + // Delegate animation tick to FrameAnimator; it advances springs, + // refreshes the frame slice, and updates the subtree highlight. + m.FrameAnimator.tickAnimation(&m.SelectionManager, &m.SearchController) return m, m.animationTickCmd() case flameSnapshotReadyMsg: return m.handleSnapshotReady(msg) @@ -267,28 +253,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // handleSearchInput processes key events while search mode is active. -// Handles escape (cancel search), enter (apply query), and delegates -// all other keys to the text input component for in-place editing. +// Delegates key dispatch (esc/enter/text) to SearchController, then updates +// match state and status message on the Model. func (m Model) handleSearchInput(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { - handled := false - switch msg.String() { - case "esc": - handled = true - m.clearSearch() - m.recordKeyDebug(msg, handled, false) - return m, nil - case "enter": - handled = true - m.applySearchQuery(m.searchInput.Value()) - m.searchActive = false - m.searchInput.Blur() - m.recordKeyDebug(msg, handled, false) - return m, nil + _, committed, query, cancelled := m.SearchController.handleInput(msg) + switch { + case cancelled: + // ESC: clear search state and close search mode. + m.statusMessage = m.SearchController.clear() + m.recordKeyDebug(msg, true, false) + case committed: + // Enter: apply query, close search mode, jump to first match. + m.SearchController.searchActive = false + statusMsg, jumpDir := m.SearchController.applyQuery(query, m.frames, m.ancestry) + m.statusMessage = statusMsg + if jumpDir != 0 { + m.selectedIdx, m.subtreeSet = jumpMatch(m.frames, m.matchIndices, m.ancestry, m.selectedIdx, jumpDir) + } else { + m.SelectionManager.ensureNavigable(m.frames, m.matchIndices, m.searchQuery, m.filterVisible) + } + m.recordKeyDebug(msg, true, false) + default: + m.recordKeyDebug(msg, true, false) } - var cmd tea.Cmd - m.searchInput, cmd = m.searchInput.Update(msg) - _ = cmd - m.recordKeyDebug(msg, true, false) return m, nil } @@ -315,9 +302,10 @@ func (m *Model) handleModeKey(msg tea.KeyPressMsg) bool { case isSearchOpenKey(msg): m.openSearch() case isNextMatchKey(msg): - m.jumpMatch(1) + // Delegate match jump to package-level helper; update selection and subtree. + m.selectedIdx, m.subtreeSet = jumpMatch(m.frames, m.matchIndices, m.ancestry, m.selectedIdx, 1) case isPrevMatchKey(msg): - m.jumpMatch(-1) + m.selectedIdx, m.subtreeSet = jumpMatch(m.frames, m.matchIndices, m.ancestry, m.selectedIdx, -1) case isPauseKey(msg): m.togglePause() case isResetBaselineKey(msg): @@ -340,22 +328,26 @@ func (m *Model) handleModeKey(msg tea.KeyPressMsg) bool { return true } -// handleMovementKey dispatches directional and jump key actions. -// Returns true when a key was handled, false otherwise. +// handleMovementKey dispatches directional and jump key actions to +// SelectionManager. Returns true when a key was handled, false otherwise. func (m *Model) handleMovementKey(msg tea.KeyPressMsg) bool { + sel := &m.SelectionManager + frames := m.frames + sq := m.searchQuery + fv := m.filterVisible switch { case isMoveShallowerKey(msg, m.keys): - m.moveVerticalWithFallback(-1, 1, -1) + sel.moveVerticalWithFallback(frames, sq, fv, -1, 1, -1) case isMoveDeeperKey(msg, m.keys): - m.moveVerticalWithFallback(1, -1, 1) + sel.moveVerticalWithFallback(frames, sq, fv, 1, -1, 1) case isPrevSiblingKey(msg, m.keys): - m.moveSibling(-1) + sel.moveSibling(frames, -1, sq, fv) case isNextSiblingKey(msg, m.keys): - m.moveSibling(1) + sel.moveSibling(frames, 1, sq, fv) case isJumpTopKey(msg, m.keys): - m.jumpToTop() + sel.jumpToTop(frames, sq, fv) case isJumpRootKey(msg, m.keys): - m.jumpToRoot() + sel.jumpToRoot(frames, m.currentRootPath(), sq, fv) default: return false } @@ -400,14 +392,10 @@ func (m Model) handleSnapshotReady(msg flameSnapshotReadyMsg) (tea.Model, tea.Cm return m, m.animationTickCmd() } -// userDriving reports whether the user has pressed a key within the recent -// drive window. Used to skip snapshot refresh and animation while keystrokes -// are arriving. +// userDriving delegates to the FrameAnimator helper that checks whether the user +// pressed a key within the drive window. func (m Model) userDriving() bool { - if m.lastKeyAt.IsZero() { - return false - } - return time.Since(m.lastKeyAt) < driveWindow + return driveWindowActive(m.lastKeyAt) } // ConsumesKey reports whether the flamegraph should handle a key press before @@ -508,7 +496,8 @@ func (m Model) currentViewCacheKey() flameViewCacheKey { } } -// SetLiveTrie updates the data source used by the flamegraph model. +// SetLiveTrie updates the data source. Resets all sub-controllers and clears +// snapshot state so the new trie starts fresh. func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { m.liveTrie = liveTrie m.syncFieldPresetToTrie() @@ -516,19 +505,10 @@ func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { m.lastVersion = 0 m.snapshot = nil m.globalTotal = 0 - m.selectedIdx = 0 - m.frames = nil - m.targetFrames = nil - m.zoomStack = nil - m.zoomRoot = nil - m.zoomPath = "" - m.zoomLineWidth = 0 - m.subtreeSet = make(map[int]bool) - m.filterVisible = make(map[int]bool) - m.ancestry = frameAncestry{} - m.animation = NewAnimationState(30, 6.0, 1.0) - m.animating = false - m.hasNavigableSnapshot = false + m.ZoomNavigator = ZoomNavigator{} + m.SelectionManager = newSelectionManager() + m.FrameAnimator.reset() + m.SearchController.reset(false) } func (m *Model) syncFieldPresetToTrie() { @@ -598,26 +578,50 @@ func (m *Model) RefreshFromLiveTrie() bool { return true } +// buildSnapshotMsg performs the CPU-heavy snapshot+layout work on a background +// goroutine. It returns a flameSnapshotReadyMsg that the Update loop consumes +// to apply the new frame layout without blocking the UI goroutine. +func buildSnapshotMsg(liveTrie LiveTrieSource, width, height int, zoomPath string) tea.Msg { + tree, ver := liveTrie.SnapshotTree() + if tree == nil { + return flameSnapshotReadyMsg{version: ver, layoutWidth: width, layoutHeight: height, zoomPath: zoomPath} + } + var zoomRoot *snapshotNode + layoutRoot := tree + rootPath := "" + if zoomPath != "" { + zoomRoot = findNodeByPath(tree, zoomPath) + if zoomRoot != nil { + layoutRoot = zoomRoot + rootPath = zoomPath + } + } + targetFrames := buildTerminalLayoutWithPath(layoutRoot, width, height, rootPath) + if zoomPath != "" { + targetFrames = applyZoomLineage(targetFrames, tree, zoomPath, width) + } + return flameSnapshotReadyMsg{ + version: ver, + layoutWidth: width, + layoutHeight: height, + zoomPath: zoomPath, + snapshot: tree, + zoomRoot: zoomRoot, + targetFrames: targetFrames, + ancestry: buildFrameAncestry(targetFrames), + globalTotal: snapshotTotal(tree), + } +} + // RefreshFromLiveTrieCmd returns a tea.Cmd that fetches a snapshot, lays out // frames, and builds the ancestry index on a background goroutine, then // dispatches a flameSnapshotReadyMsg back to the Bubble Tea Update loop. // -// Returns nil if no refresh is needed: no live trie configured, paused with an -// existing snapshot, version unchanged, another refresh already in flight, or -// the user is actively pressing keys. Skipping while driving keeps keystrokes -// responsive — the next idle tick picks up the latest version. -// -// Coalescing via refreshInFlight ensures we never queue more than one -// background job at a time. Newer ticks just no-op until the in-flight result -// lands, and the version gate then catches the freshest state. +// Returns nil when no refresh is needed: no live trie, paused with an existing +// snapshot, version unchanged, another refresh in flight, or user driving. +// Coalescing via refreshInFlight ensures at most one background job at a time. func (m *Model) RefreshFromLiveTrieCmd() tea.Cmd { - if m.liveTrie == nil { - return nil - } - if m.paused && m.snapshot != nil { - return nil - } - if m.refreshInFlight { + if m.liveTrie == nil || (m.paused && m.snapshot != nil) || m.refreshInFlight { return nil } if m.userDriving() && m.snapshot != nil { @@ -628,44 +632,11 @@ func (m *Model) RefreshFromLiveTrieCmd() tea.Cmd { return nil } m.refreshInFlight = true - - // Capture fields the goroutine needs. Avoids reading Model fields - // concurrently with the Update goroutine. - liveTrie := m.liveTrie - width := m.width - height := m.height - zoomPath := m.zoomPath + // Capture the fields needed by the goroutine to avoid concurrent reads of + // Model fields from outside the Bubble Tea Update goroutine. + liveTrie, width, height, zoomPath := m.liveTrie, m.width, m.height, m.zoomPath return func() tea.Msg { - tree, ver := liveTrie.SnapshotTree() - if tree == nil { - return flameSnapshotReadyMsg{version: ver, layoutWidth: width, layoutHeight: height, zoomPath: zoomPath} - } - var zoomRoot *snapshotNode - layoutRoot := tree - rootPath := "" - if zoomPath != "" { - zoomRoot = findNodeByPath(tree, zoomPath) - if zoomRoot != nil { - layoutRoot = zoomRoot - rootPath = zoomPath - } - } - targetFrames := buildTerminalLayoutWithPath(layoutRoot, width, height, rootPath) - if zoomPath != "" { - targetFrames = applyZoomLineage(targetFrames, tree, zoomPath, width) - } - ancestry := buildFrameAncestry(targetFrames) - return flameSnapshotReadyMsg{ - version: ver, - layoutWidth: width, - layoutHeight: height, - zoomPath: zoomPath, - snapshot: tree, - zoomRoot: zoomRoot, - targetFrames: targetFrames, - ancestry: ancestry, - globalTotal: snapshotTotal(tree), - } + return buildSnapshotMsg(liveTrie, width, height, zoomPath) } } @@ -699,10 +670,11 @@ func (m *Model) SetViewport(width, height int) { m.rebuildFrames(true) } -// SetDarkMode sets the active color theme mode. +// SetDarkMode sets the active color theme mode. Delegates the text input style +// update to SearchController. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark - m.searchInput.SetStyles(textinput.DefaultStyles(isDark)) + m.SearchController.setDarkMode(isDark) } func (m *Model) rebuildFrames(animate bool) { @@ -728,46 +700,17 @@ func (m *Model) rebuildFrames(animate bool) { } // applyTargetFrames installs a prebuilt frame layout and ancestry index, -// optionally animating from the previous frames. Shared between the -// synchronous rebuildFrames path and the async snapshot-ready handler so -// post-swap state stays consistent (animation kickoff, selection restore, -// filter recompute, subtree highlight). +// optionally animating from the previous frames. Delegates the swap, selection +// restore, filter recompute, and subtree-highlight update to FrameAnimator so +// the post-swap invariants are enforced in one place. func (m *Model) applyTargetFrames(targetFrames []tuiFrame, ancestry frameAncestry, prevPath string, animate bool) { - m.targetFrames = targetFrames - m.ancestry = ancestry - m.animation.SetTargets(m.targetFrames) - if animate && len(m.frames) > 0 && !m.animation.Settled() { - m.animating = true - m.frames = m.animation.CurrentFrames() - } else { - m.animating = false - m.frames = append(m.frames[:0], m.targetFrames...) - } - if len(m.frames) > 1 { - m.hasNavigableSnapshot = true - } - m.restoreSelectionByPath(prevPath) - m.clampSelection() - m.recomputeFilterState() - m.ensureSelectionNavigable() - m.ensureSelectionVisible() - m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) + m.FrameAnimator.applyTargetFrames(targetFrames, ancestry, prevPath, animate, &m.SelectionManager, &m.SearchController, m.height) } +// restoreSelectionByPath delegates to SelectionManager to restore the selection +// after a frame layout swap. func (m *Model) restoreSelectionByPath(path string) { - if path == "" || len(m.frames) == 0 { - return - } - if idx := m.frameIndexByPath(path); idx >= 0 { - m.selectedIdx = idx - return - } - for idx, frame := range m.frames { - if hasPathBoundaryPrefix(path, frame.Path) || hasPathBoundaryPrefix(frame.Path, path) { - m.selectedIdx = idx - return - } - } + m.SelectionManager.restoreByPath(m.frames, path) } func (m Model) frameIndexByPath(path string) int { @@ -818,209 +761,20 @@ func (m *Model) zoomUndo() { m.statusMessage = "Zoom: " + compactFramePath(m.zoomPath) } +// zoomReset resets the zoom to the full tree. Delegates the "already at root" +// check to ZoomNavigator.alreadyAtRoot, and the state clear to ZoomNavigator.reset. func (m *Model) zoomReset() { - if m.zoomRoot == nil && len(m.zoomStack) == 0 { + if m.ZoomNavigator.alreadyAtRoot() { m.statusMessage = "Zoom already at root" return } - m.zoomRoot = nil - m.zoomPath = "" - m.zoomStack = nil - m.zoomLineWidth = 0 + m.statusMessage = m.ZoomNavigator.reset() m.rebuildFrames(false) - m.statusMessage = "Zoom reset to root" -} - -func (m *Model) moveVertical(delta int) { - if len(m.frames) == 0 { - return - } - m.clampSelection() - m.ensureSelectionNavigable() - current := m.frames[m.selectedIdx] - targetDepth := current.Depth + delta - targets := m.framesAtDepth(targetDepth) - if len(targets) == 0 { - return - } - best := targets[0] - bestDist := abs(m.frames[best].Col - current.Col) - for _, idx := range targets[1:] { - dist := abs(m.frames[idx].Col - current.Col) - if dist < bestDist { - best = idx - bestDist = dist - } - } - m.selectedIdx = best -} - -func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta, traversalDelta int) { - before := m.selectedIdx - m.moveVertical(primaryDelta) - if m.selectedIdx == before && fallbackDelta != 0 { - m.moveVertical(fallbackDelta) - } - if m.selectedIdx == before && traversalDelta != 0 { - m.moveTraversal(traversalDelta) - } -} - -func (m *Model) moveSibling(delta int) { - if len(m.frames) == 0 { - return - } - before := m.selectedIdx - m.clampSelection() - m.ensureSelectionNavigable() - current := m.frames[m.selectedIdx] - siblings := m.framesAtDepth(current.Depth) - if len(siblings) <= 1 { - m.moveTraversal(delta) - return - } - pos := indexOf(siblings, m.selectedIdx) - if pos < 0 { - m.moveTraversal(delta) - return - } - next := pos + delta - if next < 0 { - next = 0 - } - if next >= len(siblings) { - next = len(siblings) - 1 - } - m.selectedIdx = siblings[next] - if m.selectedIdx == before { - m.moveTraversal(delta) - } -} - -func (m *Model) jumpToTop() { - if len(m.frames) == 0 { - return - } - m.clampSelection() - m.ensureSelectionNavigable() - - include := m.navigableFrameSet() - currentCol := m.frames[m.selectedIdx].Col - bestIdx := -1 - bestDepth := -1 - bestDist := int(^uint(0) >> 1) - - for idx, frame := range m.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 < m.frames[bestIdx].Col) { - bestIdx = idx - bestDist = dist - } - } - } - if bestIdx >= 0 { - m.selectedIdx = bestIdx - } -} - -func (m *Model) jumpToRoot() { - if len(m.frames) == 0 { - return - } - m.clampSelection() - m.ensureSelectionNavigable() - - rootPath := m.currentRootPath() - if rootPath != "" { - if idx := m.frameIndexByPath(rootPath); idx >= 0 { - if !m.filterActive() || m.frameNavigable(idx) { - m.selectedIdx = idx - return - } - } - } - - include := m.navigableFrameSet() - currentCol := m.frames[m.selectedIdx].Col - bestIdx := -1 - bestDepth := int(^uint(0) >> 1) - bestDist := int(^uint(0) >> 1) - for idx, frame := range m.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 < m.frames[bestIdx].Col) { - bestDist = dist - bestIdx = idx - } - } - } - if bestIdx >= 0 { - m.selectedIdx = bestIdx - } -} - -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 -} - -func indexOf(values []int, target int) int { - for idx, value := range values { - if value == target { - return idx - } - } - return -1 } +// clampSelection delegates to SelectionManager to keep selectedIdx in bounds. func (m *Model) clampSelection() { - if len(m.frames) == 0 { - m.selectedIdx = 0 - return - } - if m.selectedIdx < 0 { - m.selectedIdx = 0 - } - if m.selectedIdx >= len(m.frames) { - m.selectedIdx = len(m.frames) - 1 - } + m.SelectionManager.clamp(m.frames) } func abs(v int) int { @@ -1037,66 +791,36 @@ func (m Model) animationTickCmd() tea.Cmd { return tea.Tick(animFrameDuration, func(time.Time) tea.Msg { return animTickMsg{} }) } +// currentRootPath delegates to ZoomNavigator to return the current view root path. func (m Model) currentRootPath() string { - if m.zoomPath != "" { - return m.zoomPath - } - if len(m.frames) == 0 { - return "" - } - return m.frames[0].Path + return m.ZoomNavigator.currentRootPath(m.frames) } +// filterActive reports whether a search filter is applied. func (m Model) filterActive() bool { - return strings.TrimSpace(m.searchQuery) != "" + return filterActive(m.searchQuery) } +// navigableFrameSet returns the filter-visible set when a filter is active, else nil. func (m Model) navigableFrameSet() map[int]bool { - if !m.filterActive() { - return nil - } - return m.filterVisible + return navigableSet(m.searchQuery, m.filterVisible) } +// framesAtDepth returns frame indices at the given depth filtered by the +// current search. func (m Model) framesAtDepth(depth int) []int { return framesAtDepthFiltered(m.frames, depth, m.navigableFrameSet()) } +// frameNavigable reports whether a frame can be selected under the current filter. func (m Model) frameNavigable(idx int) bool { - if idx < 0 || idx >= len(m.frames) { - return false - } - if !m.filterActive() { - return true - } - return m.filterVisible[idx] + return frameNavigable(idx, m.frames, m.searchQuery, m.filterVisible) } +// ensureSelectionNavigable delegates to SelectionManager to keep the selection +// on a frame that is visible under the current filter. func (m *Model) ensureSelectionNavigable() { - if len(m.frames) == 0 { - m.selectedIdx = 0 - return - } - m.clampSelection() - if m.frameNavigable(m.selectedIdx) { - return - } - - if len(m.matchIndices) > 0 { - for _, idx := range orderedMatchIndices(m.matchIndices) { - if m.frameNavigable(idx) { - m.selectedIdx = idx - return - } - } - } - - for idx := range m.frames { - if m.frameNavigable(idx) { - m.selectedIdx = idx - return - } - } + m.SelectionManager.ensureNavigable(m.frames, m.matchIndices, m.searchQuery, m.filterVisible) } func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) { @@ -1115,52 +839,14 @@ func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) { m.lastKeyDebug = fmt.Sprintf("dbg frames=%d idx=%d key=%q code=%d handled=%t moved=%t sel=%s", len(m.frames), selIdx, keyID, msg.Code, handled, moved, sel) } +// moveTraversal delegates depth-then-column traversal to SelectionManager. func (m *Model) moveTraversal(delta int) { - if len(m.frames) == 0 || delta == 0 { - return - } - order := m.visibleTraversalOrder() - if len(order) == 0 { - return - } - pos := indexOf(order, m.selectedIdx) - if pos < 0 { - pos = 0 - } - next := pos + delta - if next < 0 { - next = 0 - } - if next >= len(order) { - next = len(order) - 1 - } - m.selectedIdx = order[next] + m.SelectionManager.moveTraversal(m.frames, delta, m.searchQuery, m.filterVisible) } +// visibleTraversalOrder delegates to SelectionManager for the sorted traversal order. func (m Model) visibleTraversalOrder() []int { - indices := make([]int, 0, len(m.frames)) - include := m.navigableFrameSet() - for idx := range m.frames { - if include != nil && !include[idx] { - continue - } - indices = append(indices, idx) - } - slices.SortFunc(indices, func(a, b int) int { - left := m.frames[a] - right := m.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 + return visibleTraversalOrder(m.frames, m.searchQuery, m.filterVisible) } func keyString(msg tea.KeyPressMsg) string { @@ -1250,54 +936,15 @@ func isArrowEscapeSequence(value string, ansiFinal byte) bool { } } +// visibleRowOffset delegates row offset calculation to SelectionManager. func (m Model) visibleRowOffset() int { - if len(m.frames) == 0 { - return 0 - } - availableRows := m.height - 2 // toolbar + status - if availableRows <= 0 { - return 0 - } - maxRow := maxFrameRowForSet(m.frames, m.navigableFrameSet()) - if maxRow+1 <= availableRows { - return 0 - } - return maxRow + 1 - availableRows + return visibleRowOffset(m.frames, m.height, m.searchQuery, m.filterVisible) } +// ensureSelectionVisible delegates to SelectionManager to adjust the selection +// so it falls within the visible rendered rows. func (m *Model) ensureSelectionVisible() { - if len(m.frames) == 0 { - return - } - m.clampSelection() - m.ensureSelectionNavigable() - if !m.frameNavigable(m.selectedIdx) { - return - } - rowOffset := m.visibleRowOffset() - selected := m.frames[m.selectedIdx] - if selected.Row >= rowOffset { - return - } - - bestIdx := -1 - bestScore := int(^uint(0) >> 1) - for idx, frame := range m.frames { - if !m.frameNavigable(idx) { - 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 { - m.selectedIdx = bestIdx - } + m.SelectionManager.ensureVisible(m.frames, m.height, m.searchQuery, m.filterVisible) } func (m *Model) handleMouseClick(msg tea.MouseClickMsg) bool { @@ -1358,112 +1005,24 @@ func (m *Model) setZoomPath(path string) bool { return true } +// rootSnapshotPath delegates to ZoomNavigator to derive the canonical root path. func (m Model) rootSnapshotPath() string { - if m.snapshot != nil { - return frameName(m.snapshot.Name, 0) - } - if len(m.frames) > 0 { - return m.frames[0].Path - } - return "" + return m.ZoomNavigator.rootSnapshotPath(m.snapshot, m.frames) } -func buildZoomStack(path string) []zoomState { - parts := strings.Split(path, pathSeparator) - if len(parts) <= 1 { - return nil - } - stack := []zoomState{{path: ""}} - for idx := 1; idx < len(parts)-1; idx++ { - stack = append(stack, zoomState{path: strings.Join(parts[:idx+1], pathSeparator)}) - } - return stack -} -// frameIndexAt returns the index of the frame rendered at terminal coordinates -// (x, y), or -1 if no frame occupies that cell. Delegates boundary validation -// to frameCoordToTargetRow and frame scanning to findFrameAtRow. +// frameIndexAt delegates to the FrameAnimator package-level helper to convert +// terminal coordinates (x, y) to a frame index, accounting for UI chrome. func (m Model) frameIndexAt(x, y int) int { - if len(m.frames) == 0 || m.width <= 0 || m.height <= 0 { - return -1 - } - if x < 0 || x >= m.width || y < 0 { - return -1 - } - - extraLines := 1 // selection status line - if m.showHelp { - extraLines++ - } - renderHeight := m.height - extraLines - if renderHeight < 3 { - renderHeight = 3 - } - availableRows := renderHeight - 2 // flame toolbar + frame-status line - if availableRows < 1 { - return -1 - } - - // Row 0 is flame toolbar, rows 1..availableRows are bars, last row is status. - if y < 1 || y > availableRows { - return -1 - } - - targetRow := m.frameCoordToTargetRow(y-1, availableRows) - if targetRow < 0 { - return -1 - } - return findFrameAtRow(m.frames, targetRow, x, m.width) + return frameIndexAt(m.frames, x, y, m.width, m.height, m.showHelp) } -// frameCoordToTargetRow converts a data-area row offset (0-based, after -// stripping the toolbar row) into the logical frame row index. Returns -1 when -// the coordinate falls in the top padding area above the first visible row. +// frameCoordToTargetRow delegates to the FrameAnimator package-level helper. func (m Model) frameCoordToTargetRow(dataRow, availableRows int) int { - maxRow := maxFrameRowForSet(m.frames, nil) - barHeight := computeBarHeight(availableRows, maxRow+1, maxBarVisualHeight) - visibleDepthRows := availableRows / barHeight - if visibleDepthRows < 1 { - visibleDepthRows = 1 - } - rowOffset := 0 - if maxRow+1 > visibleDepthRows { - rowOffset = maxRow + 1 - visibleDepthRows - } - renderedRows := (maxRow - rowOffset + 1) * barHeight - padTop := 0 - if renderedRows < availableRows { - padTop = availableRows - renderedRows - } - if dataRow < padTop { - return -1 - } - depthFromTop := (dataRow - padTop) / barHeight - return maxRow - depthFromTop -} - -// findFrameAtRow scans frames for the narrowest one that occupies logical row -// targetRow and contains pixel column x within [0, width). Returning the -// narrowest frame resolves overlap between wide parent and narrow child bars. -func findFrameAtRow(frames []tuiFrame, targetRow, x, width int) int { - best := -1 - bestWidth := int(^uint(0) >> 1) - for idx, frame := range frames { - if frame.Row != targetRow || frame.Col >= width { - continue - } - right := min(width, frame.Col+frame.Width) - if x < frame.Col || x >= right { - continue - } - if frame.Width < bestWidth { - best = idx - bestWidth = frame.Width - } - } - return best + return frameCoordToTargetRow(m.frames, dataRow, availableRows) } + func (m Model) withZoomLineage(frames []tuiFrame) []tuiFrame { return applyZoomLineage(frames, m.snapshot, m.zoomPath, m.width) } 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 { diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go index 5cd66b1..075ce7c 100644 --- a/internal/tui/flamegraph/search.go +++ b/internal/tui/flamegraph/search.go @@ -1,115 +1,57 @@ package flamegraph import ( - "fmt" "sort" "strings" ) +// orderedMatchIndices returns the keys of matchSet as a sorted int slice. +// Used by search navigation and the search footer to determine match position. +func orderedMatchIndices(matchSet map[int]bool) []int { + matches := make([]int, 0, len(matchSet)) + for idx := range matchSet { + matches = append(matches, idx) + } + sort.Ints(matches) + return matches +} + +// openSearch activates search mode on the Model by delegating to SearchController. func (m *Model) openSearch() { - m.searchActive = true - m.searchInput.SetValue(m.searchQuery) - m.searchInput.CursorEnd() - m.searchInput.Focus() + m.SearchController.open() } +// clearSearch deactivates search mode and clears all search state. +// Delegates to SearchController and updates the Model status message. func (m *Model) clearSearch() { - m.searchActive = false - m.searchQuery = "" - clearBoolMap(m.matchIndices) - clearBoolMap(m.filterVisible) - m.searchInput.SetValue("") - m.searchInput.Blur() - m.statusMessage = "Filter cleared" + m.statusMessage = m.SearchController.clear() } +// applySearchQuery applies a new search query, rebuilds filter state, and jumps +// to the first match. Delegates to SearchController.applyQuery, then applies +// any resulting selection change via the shared jumpMatch helper. func (m *Model) applySearchQuery(raw string) { - m.searchQuery = strings.ToLower(strings.TrimSpace(raw)) - m.recomputeFilterState() - query := m.searchQuery - if query == "" { - m.ensureSelectionNavigable() - m.statusMessage = "Filter cleared" - return - } - - if len(m.matchIndices) > 0 { - m.jumpMatch(1) - m.statusMessage = fmt.Sprintf("Filter %q: %d matches", query, len(m.matchIndices)) - return - } - m.statusMessage = fmt.Sprintf("Filter %q: no matches", query) -} - -func (m *Model) jumpMatch(direction int) { - matches := orderedMatchIndices(m.matchIndices) - if len(matches) == 0 { - return - } - currentPos := indexOf(matches, m.selectedIdx) - if currentPos == -1 { - if direction < 0 { - m.selectedIdx = matches[len(matches)-1] - } else { - m.selectedIdx = matches[0] - } - m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) - return - } - - next := currentPos + direction - if next < 0 { - next = len(matches) - 1 - } - if next >= len(matches) { - next = 0 - } - m.selectedIdx = matches[next] - m.subtreeSet = subtreeSetUsingAncestry(m.frames, m.selectedIdx, m.ancestry, m.subtreeSet) -} - -func (m *Model) recomputeFilterState() { - if m.matchIndices == nil { - m.matchIndices = make(map[int]bool) + statusMsg, jumpDir := m.SearchController.applyQuery(raw, m.frames, m.ancestry) + m.statusMessage = statusMsg + if jumpDir != 0 { + m.selectedIdx, m.subtreeSet = jumpMatch( + m.frames, m.matchIndices, m.ancestry, m.selectedIdx, jumpDir, + ) } else { - clearBoolMap(m.matchIndices) - } - if m.filterVisible == nil { - m.filterVisible = make(map[int]bool) - } else { - clearBoolMap(m.filterVisible) - } - if m.searchQuery == "" { - return - } - - for idx, frame := range m.frames { - if strings.Contains(strings.ToLower(frame.Name), m.searchQuery) { - m.matchIndices[idx] = true - } + m.SelectionManager.ensureNavigable(m.frames, m.matchIndices, m.searchQuery, m.filterVisible) } - m.filterVisible = filterVisibleSetUsingAncestry(m.frames, m.matchIndices, m.ancestry, m.filterVisible) } -func orderedMatchIndices(matchSet map[int]bool) []int { - matches := make([]int, 0, len(matchSet)) - for idx := range matchSet { - matches = append(matches, idx) - } - sort.Ints(matches) - return matches +// recomputeFilterState rebuilds the match and filter-visible sets after a +// frame layout change. Delegates to SearchController.recomputeFilterState. +func (m *Model) recomputeFilterState() { + m.SearchController.recomputeFilterState(m.frames, m.ancestry) } +// searchFooter renders the search bar with match position info. Delegates to +// SearchController.footerLine. func (m Model) searchFooter() string { - matches := orderedMatchIndices(m.matchIndices) - pos := 0 - if len(matches) > 0 { - idx := indexOf(matches, m.selectedIdx) - if idx >= 0 { - pos = idx + 1 - } - } - return fmt.Sprintf("%s %d/%d matches", m.searchInput.View(), pos, len(matches)) + return m.SearchController.footerLine(m.frames, m.selectedIdx) } func replaceFooterLine(content, footer string) string { diff --git a/internal/tui/flamegraph/search_controller.go b/internal/tui/flamegraph/search_controller.go new file mode 100644 index 0000000..8bf00b1 --- /dev/null +++ b/internal/tui/flamegraph/search_controller.go @@ -0,0 +1,168 @@ +package flamegraph + +import ( + "fmt" + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" +) + +// SearchController owns all search/filter state and operations. It manages the +// text input widget, the current query string, the set of matching frame indices, +// and the filter-visible set that drives navigation when a filter is active. +type SearchController struct { + searchActive bool + searchInput textinput.Model + searchQuery string + matchIndices map[int]bool + filterVisible map[int]bool +} + +// newSearchController constructs a SearchController with a configured text input. +func newSearchController(isDark bool) SearchController { + input := textinput.New() + input.Prompt = "/" + input.CharLimit = 0 + input.SetWidth(32) + input.SetStyles(textinput.DefaultStyles(isDark)) + return SearchController{ + matchIndices: make(map[int]bool), + filterVisible: make(map[int]bool), + searchInput: input, + } +} + +// open activates search mode, restoring the current query into the text input. +func (sc *SearchController) open() { + sc.searchActive = true + sc.searchInput.SetValue(sc.searchQuery) + sc.searchInput.CursorEnd() + sc.searchInput.Focus() +} + +// clear deactivates search mode and wipes the query and index maps. +// Returns a status message. +func (sc *SearchController) clear() string { + sc.searchActive = false + sc.searchQuery = "" + clearBoolMap(sc.matchIndices) + clearBoolMap(sc.filterVisible) + sc.searchInput.SetValue("") + sc.searchInput.Blur() + return "Filter cleared" +} + +// applyQuery stores a new search query and rebuilds the match/filter sets. +// Returns the status message to display and the direction to jump (0 = no jump). +func (sc *SearchController) applyQuery(raw string, frames []tuiFrame, ancestry frameAncestry) (statusMsg string, jumpDir int) { + sc.searchQuery = strings.ToLower(strings.TrimSpace(raw)) + sc.recomputeFilterState(frames, ancestry) + query := sc.searchQuery + if query == "" { + return "Filter cleared", 0 + } + if len(sc.matchIndices) > 0 { + return fmt.Sprintf("Filter %q: %d matches", query, len(sc.matchIndices)), 1 + } + return fmt.Sprintf("Filter %q: no matches", query), 0 +} + +// handleInput processes a key event while search mode is active. Returns the +// updated text input command, plus booleans for whether the search was committed +// or cancelled. The committed value carries the final query string. +func (sc *SearchController) handleInput(msg tea.KeyPressMsg) (cmd tea.Cmd, committed bool, query string, cancelled bool) { + switch msg.String() { + case "esc": + return nil, false, "", true + case "enter": + return nil, true, sc.searchInput.Value(), false + } + var c tea.Cmd + sc.searchInput, c = sc.searchInput.Update(msg) + return c, false, "", false +} + +// recomputeFilterState rebuilds matchIndices and filterVisible from the current +// query and the provided frame slice + ancestry index. +func (sc *SearchController) recomputeFilterState(frames []tuiFrame, ancestry frameAncestry) { + if sc.matchIndices == nil { + sc.matchIndices = make(map[int]bool) + } else { + clearBoolMap(sc.matchIndices) + } + if sc.filterVisible == nil { + sc.filterVisible = make(map[int]bool) + } else { + clearBoolMap(sc.filterVisible) + } + if sc.searchQuery == "" { + return + } + for idx, frame := range frames { + if strings.Contains(strings.ToLower(frame.Name), sc.searchQuery) { + sc.matchIndices[idx] = true + } + } + sc.filterVisible = filterVisibleSetUsingAncestry(frames, sc.matchIndices, ancestry, sc.filterVisible) +} + +// footerLine renders the search bar with the match count. Called by View when +// search is active. +func (sc *SearchController) footerLine(frames []tuiFrame, selectedIdx int) string { + matches := orderedMatchIndices(sc.matchIndices) + pos := 0 + if len(matches) > 0 { + idx := indexOf(matches, selectedIdx) + if idx >= 0 { + pos = idx + 1 + } + } + return fmt.Sprintf("%s %d/%d matches", sc.searchInput.View(), pos, len(matches)) +} + +// jumpMatch moves the selection to the next or previous match (direction +1/-1). +// Returns the new selectedIdx (unchanged if there are no matches). +func jumpMatch(frames []tuiFrame, matchIndices map[int]bool, ancestry frameAncestry, selectedIdx, direction int) (int, map[int]bool) { + matches := orderedMatchIndices(matchIndices) + if len(matches) == 0 { + return selectedIdx, nil + } + currentPos := indexOf(matches, selectedIdx) + var nextIdx int + if currentPos == -1 { + if direction < 0 { + nextIdx = matches[len(matches)-1] + } else { + nextIdx = matches[0] + } + } else { + next := currentPos + direction + if next < 0 { + next = len(matches) - 1 + } + if next >= len(matches) { + next = 0 + } + nextIdx = matches[next] + } + subtree := subtreeSetUsingAncestry(frames, nextIdx, ancestry, nil) + return nextIdx, subtree +} + +// setDarkMode updates the text input style for the given theme. +func (sc *SearchController) setDarkMode(isDark bool) { + sc.searchInput.SetStyles(textinput.DefaultStyles(isDark)) +} + +// reset clears all search state, keeping the text input widget. +func (sc *SearchController) reset(clearQuery bool) { + sc.searchActive = false + if clearQuery { + sc.searchQuery = "" + sc.searchInput.SetValue("") + } + clearBoolMap(sc.matchIndices) + clearBoolMap(sc.filterVisible) + sc.searchInput.Blur() +} 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 +} diff --git a/internal/tui/flamegraph/zoom_navigator.go b/internal/tui/flamegraph/zoom_navigator.go new file mode 100644 index 0000000..e46085d --- /dev/null +++ b/internal/tui/flamegraph/zoom_navigator.go @@ -0,0 +1,65 @@ +package flamegraph + +import "strings" + +// ZoomNavigator manages the zoom path, stack, and root node for the flamegraph +// view. It tracks the current zoom level, supports undo via a stack, and rebuilds +// the frame layout whenever the zoom target changes. +type ZoomNavigator struct { + zoomPath string + zoomStack []zoomState + zoomRoot *snapshotNode + zoomLineWidth int +} + +// currentRootPath returns the path of the current view root. +// When zoomed in, that is the zoom path; otherwise it is the first frame's path. +func (z *ZoomNavigator) currentRootPath(frames []tuiFrame) string { + if z.zoomPath != "" { + return z.zoomPath + } + if len(frames) == 0 { + return "" + } + return frames[0].Path +} + +// rootSnapshotPath returns the canonical root path derived from the snapshot or frames. +func (z *ZoomNavigator) rootSnapshotPath(snapshot *snapshotNode, frames []tuiFrame) string { + if snapshot != nil { + return frameName(snapshot.Name, 0) + } + if len(frames) > 0 { + return frames[0].Path + } + return "" +} + +// reset clears all zoom state and returns a status message. +func (z *ZoomNavigator) reset() string { + z.zoomRoot = nil + z.zoomPath = "" + z.zoomStack = nil + z.zoomLineWidth = 0 + return "Zoom reset to root" +} + +// alreadyAtRoot reports whether no zoom is active and the stack is empty. +func (z *ZoomNavigator) alreadyAtRoot() bool { + return z.zoomRoot == nil && len(z.zoomStack) == 0 +} + +// buildZoomStack builds the ancestor zoom stack for a direct deep-zoom path. +// It creates entries for every path prefix so undo walks back up one step at a +// time: root → A → A/A1 etc. +func buildZoomStack(path string) []zoomState { + parts := strings.Split(path, pathSeparator) + if len(parts) <= 1 { + return nil + } + stack := []zoomState{{path: ""}} + for idx := 1; idx < len(parts)-1; idx++ { + stack = append(stack, zoomState{path: strings.Join(parts[:idx+1], pathSeparator)}) + } + return stack +} |
