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