package editor // In-file search (find bar). // // Model: // - FindState is logic-owned (EditorState.Find); only the logic goroutine // mutates it. // - The find bar's text field is a MAIN-owned widget.Editor registered as // "find_bar" (the browser search bar "search_bar" is the precedent, // architecture.md ยง1): its text reaches the logic only through // Logic.findQueryChan. The find icon, counter, and prev/next/close // buttons are plain elements whose taps run the handlers below on the // logic goroutine. // - A query is scanned by a pool SearchTask over a snapshot of the // FULL in-memory content (in-range files are fully loaded, so the // snapshot is exact and unsaved edits are searchable). Results carry a // generation; only the latest generation is applied, so typing fast // simply supersedes older scans. // - Edits do NOT invalidate search: matches outside the edited range are // shifted by the length delta, matches overlapping it are dropped // (findEdit). The count is therefore "as of the last scan, adjusted for // edits" โ€” re-typing the query re-scans. import ( "sort" "pad/internal/io/pool" "pad/internal/ui" ) // FindState holds the in-file search state for the open file. type FindState struct { Visible bool // the find bar is shown Query string // the query the logic last processed Matches [][2]int // ascending, non-overlapping byte ranges Cur int // index into Matches; -1 when none is selected Gen uint64 // generation of the newest dispatched scan Scanning bool // a scan for Gen is in flight } // ToggleFind opens the find bar (or closes it when open). It is the tap // handler of the find icon in the editor top bar. func ToggleFind(data any) { e := &TheState.Editor if e.Find.Visible { e.findClose() } else { e.findShow() } } // FindNext / FindPrev move to the next/previous match, wrap around, select // it, and scroll it into view. They are the tap handlers of the find bar's // chevron buttons. func FindNext(data any) { findStep(1) } func FindPrev(data any) { findStep(-1) } // FindClose is the tap handler of the find bar's close button. func FindClose(data any) { TheState.Editor.findClose() } // findShow opens the find bar. The text input is main-owned, so logic takes // key focus off the editor (FocusedElementID) and, if a previous query has // no live matches (they are cleared on close and on file open), re-scans it. func (e *EditorState) findShow() { e.Find.Visible = true if e.Filename != "" { TheState.FocusedElementID = "find_bar" } if e.Find.Query != "" && len(e.Find.Matches) == 0 && !e.Find.Scanning { e.findDispatchScan() } } // findClose hides the find bar. The query is kept so re-opening shows it // (the main-owned widget keeps its text); matches are dropped โ€” the next // show re-scans, so they are never stale. func (e *EditorState) findClose() { e.Find.Visible = false e.Find.Matches = nil e.Find.Cur = -1 e.Find.Scanning = false if TheState.FocusedElementID == "find_bar" { TheState.FocusedElementID = "editor_text" } } // findReset discards all find state for the open file (file open, page // change). The query is kept: the main-owned widget still shows it, and // keeping it lets the next show() re-scan the NEW file with the old query // (and a frame with a changed FindQuery re-sends it, re-scanning live). func (e *EditorState) findReset() { // Monotonic generation bump (do not zero the counter): a stale // in-flight scan carrying a HIGH generation must never equal a fresh // one, or its result would pass the gen gate in applySearchResult. e.Find = FindState{Query: e.Find.Query, Gen: e.Find.Gen + 1} } // findSetQuery processes a query forwarded from the main-owned "find_bar" // widget. An empty query clears the results; otherwise a fresh scan of the // full in-memory content is dispatched on the worker pool. func (e *EditorState) findSetQuery(q string) { f := &e.Find f.Query = q f.Matches = nil f.Cur = -1 if q == "" || e.TooLarge { f.Scanning = false return } e.findDispatchScan() } // findDispatchScan snapshots the full content and dispatches a SearchTask. // Must be called on the logic goroutine. func (e *EditorState) findDispatchScan() { f := &e.Find f.Gen++ f.Scanning = true if TheLogic == nil || TheLogic.workerPool == nil { // unit tests return } text := e.Buffer if cb := e.ChunkedBuffer; cb != nil { if full, err := cb.FullContent(); err == nil { text = full } } TheLogic.workerPool.Dispatch(pool.NewSearchTask(text, f.Query, f.Gen)) } // applySearchResult applies a completed SearchTask result. Stale generations // (a newer query superseded this scan) are dropped. Must be called on the // logic goroutine. func (e *EditorState) applySearchResult(res pool.Result) { f := &e.Find var sd pool.SearchData switch v := res.Data.(type) { case pool.SearchData: sd = v case *pool.SearchData: sd = *v default: return } if sd.Gen != f.Gen || !f.Visible { return } f.Scanning = false wasEmpty := len(f.Matches) == 0 prevStart := -1 if f.Cur >= 0 && f.Cur < len(f.Matches) { prevStart = f.Matches[f.Cur][0] } f.Matches = sd.Matches f.Cur = -1 if len(f.Matches) > 0 { // Keep the previously current match when it survived the // re-scan; otherwise select the first one. if prevStart >= 0 { if i := sort.Search(len(f.Matches), func(i int) bool { return f.Matches[i][0] >= prevStart }); i < len(f.Matches) && f.Matches[i][0] == prevStart { f.Cur = i } } if f.Cur < 0 { f.Cur = 0 } // The first discovery of matches (matches went 0 -> N) selects and // scrolls the view to the current match; while the user keeps // typing, results update in place and the view moves only on // explicit next/prev. if wasEmpty { SetSelection(f.Matches[f.Cur][0], f.Matches[f.Cur][1]) scrollToFindMatch(f.Matches[f.Cur][0]) } } } // findStep selects the next (dir>0) or previous (dir<0) match, wrapping // around. With no current match yet, "next" goes to the first match at or // after the current selection start/caret (wrapping to the first) and // "prev" to the last match ending before the selection end (wrapping to the // last). func findStep(dir int) { e := &TheState.Editor f := &e.Find n := len(f.Matches) if n == 0 { return } idx := -1 if f.Cur >= 0 { idx = (f.Cur + dir + n) % n } else { ref := e.CursorPosition if selActive() { if dir > 0 { ref = e.SelectionStart } else { ref = e.SelectionEnd } } if dir > 0 { for i, m := range f.Matches { if m[0] >= ref { idx = i break } } if idx < 0 { idx = 0 // wrap to the first } } else { for i := n - 1; i >= 0; i-- { if f.Matches[i][1] < ref { idx = i break } } if idx < 0 { idx = n - 1 // wrap to the last } } } m := f.Matches[idx] f.Cur = idx SetSelection(m[0], m[1]) scrollToFindMatch(m[0]) } // scrollToFindMatch scrolls so the visual line containing absByte sits at // the top of the editor viewport (clamped to [0, MaxScroll]). func scrollToFindMatch(absByte int) { cb := TheState.Editor.ChunkedBuffer if cb == nil || cb.LineIndex == nil { return } li := cb.LineIndex.FindLogicalLineForByteOffset(absByte) vl := int64(li) if w := cb.WrapIndex; w != nil { vl += int64(w.VisualsBefore(li)) } lh := float64(EffectiveLineHeight()) target := ui.Dp(float64(vl) * lh) if target < 0 { target = 0 } if target > TheState.MaxScroll { target = TheState.MaxScroll } TheState.ScrollOffset = target } // findEdit reports a length-changing edit to the find state: the byte range // [start, end) of the pre-edit content was replaced by newLen bytes. Matches // entirely before it are unchanged, matches entirely after it shift by // newLen-(end-start), and matches overlapping it are dropped (their text no // longer matches the query โ€” finding text an edit creates requires a // re-scan, which happens when the query is re-typed). func (e *EditorState) findEdit(start, end, newLen int) { f := &e.Find if len(f.Matches) == 0 { return } delta := newLen - (end - start) out := make([][2]int, 0, len(f.Matches)) cur := -1 for i, m := range f.Matches { s, mEnd := m[0], m[1] var kept [2]int switch { case mEnd <= start: kept = m case s >= end: kept = [2]int{s + delta, mEnd + delta} default: continue // overlaps the edited range: drop } if i == f.Cur { cur = len(out) } out = append(out, kept) } f.Matches = out f.Cur = cur }