diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-12 22:30:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-12 22:30:32 +0300 |
| commit | 235eb7541d6396e860b23aad63ed44c734cdf767 (patch) | |
| tree | 274d163f00605433174fa89ece82a28d4978c991 /internal/tui/flamegraph/model.go | |
| parent | 8a4cb57703845c1d8ffbc9318a4125818a72a545 (diff) | |
refactor flamegraph TUI model into focused sub-controllers
Break the god-class Model in internal/tui/flamegraph/model.go into four
focused sub-controllers that each own a single concern:
- ZoomNavigator : zoom path, stack, root node, and reset/undo logic
- SelectionManager: selected frame index, clamp, traversal, navigation
- FrameAnimator : spring animation, frame layout swap, ancestry index
- SearchController: search query, match indices, filter-visible set
All four are embedded in Model so existing field access (m.selectedIdx,
m.zoomPath, etc.) is promoted unchanged, keeping tests and callers intact.
Model methods now delegate to the sub-controllers rather than holding all
logic inline.
Also split RenderTerminalView (88 lines) into computeRenderParams,
buildToolbar, buildFilteredStatus, and buildNormalStatus helpers, and
extracted buildSnapshotMsg from RefreshFromLiveTrieCmd.
All functions are now ≤ 50 lines. All tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui/flamegraph/model.go')
| -rw-r--r-- | internal/tui/flamegraph/model.go | 769 |
1 files changed, 164 insertions, 605 deletions
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) } |
