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/search_controller.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/search_controller.go')
| -rw-r--r-- | internal/tui/flamegraph/search_controller.go | 168 |
1 files changed, 168 insertions, 0 deletions
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() +} |
