summaryrefslogtreecommitdiff
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
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>
-rw-r--r--internal/tui/flamegraph/frame_animator.go170
-rw-r--r--internal/tui/flamegraph/model.go769
-rw-r--r--internal/tui/flamegraph/renderer.go156
-rw-r--r--internal/tui/flamegraph/search.go124
-rw-r--r--internal/tui/flamegraph/search_controller.go168
-rw-r--r--internal/tui/flamegraph/selection_manager.go401
-rw-r--r--internal/tui/flamegraph/zoom_navigator.go65
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
+}