Pad/internal/editor/search.go
Greg Pomerantz 587c1c5aad Fix find scroll position on long files
Two bugs made the search jump land in the wrong place:

1. Double-counted visual lines. The visual line at which logical line li
   starts is exactly VisualsBefore(li); the code computed li +
   VisualsBefore(li), i.e. 2x the intended depth with the all-ones
   pre-shape estimates. The MaxScroll clamp masked it on small files;
   long files landed far off. Now uses VisualsBefore(li) (fallback li).

2. Truncation vs non-integer line height. Line height is 16.8dp, so
   floor(V*lh) sits just above the target line's top and the window
   decomposition floors to the line above it. The target now rounds UP:
   ceil(V*lh) is always in [V*lh, (V+1)*lh), so the viewport top
   decomposes to exactly V. The line-height source now also prefers the
   shaped GlyphLayout.LineHeight like scrollVisualDecompose/MaxScroll.

3. Estimate settle. On long wrapped files the lines above the target are
   still estimated at 1 visual line when the jump happens, so the landing
   can be short. The scroll now arms a bounded settle (SettleByte /
   SettleScroll / SettlePasses on FindState); after each layout-feedback
   wrap-count correction the logic goroutine re-runs the target and
   re-scrolls until it converges, is exhausted (4 passes), or the user /
   the MaxScroll clamp moves the viewport (which cancels it). Edits,
   query changes, close, and file open disarm the settle.

Tests: round-trip scroll-target on a 2000-line wrapped file, the
no-wrap identity, settle correction/convergence, and settle cancellation
on user scroll.
2026-08-20 06:50:14 -04:00

372 lines
12 KiB
Go

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 (
"math"
"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
// Settle* drive the post-jump viewport correction (findSettle). On long
// wrapped files the estimate-based search scroll can land short: lines
// above the target are counted as one visual line until the renderer
// shapes them. SettleByte is the target byte (-1 = inactive),
// SettleScroll the last offset the settle itself set, SettlePasses the
// remaining re-scrolls.
SettleByte int
SettleScroll ui.Dp
SettlePasses int
}
// 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
e.Find.SettleByte = -1
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, SettleByte: -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
f.SettleByte = -1 // a new query invalidates any in-flight settle
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])
}
// findScrollTarget returns the scroll offset that puts the visual line
// containing absByte at the top of the editor viewport (clamped to
// [0, MaxScroll]). ok=false when there is no line index to map through.
func findScrollTarget(absByte int) (ui.Dp, bool) {
cb := TheState.Editor.ChunkedBuffer
if cb == nil || cb.LineIndex == nil {
return 0, false
}
li := cb.LineIndex.FindLogicalLineForByteOffset(absByte)
// The visual line at which logical line li STARTS is exactly V(li) =
// VisualsBefore(li) (the visual lines of all lines before it). Do not
// add li on top: with the all-ones pre-shape estimates that would put
// the viewport at 2x the intended depth (masked by the MaxScroll clamp
// on small files, wildly wrong on long ones).
vl := int64(li)
if w := cb.WrapIndex; w != nil {
vl = int64(w.VisualsBefore(li))
}
// Same line-height preference as scrollVisualDecompose/MaxScroll, so the
// target sits in the same visual-line space the window decomposition
// uses.
lh := EffectiveLineHeight()
if g := TheState.Editor.GlyphLayout.LineHeight; g > 0 {
lh = g
}
// The scroll offset is integer Dp but the line height is not (14 * 1.2 =
// 16.8): rounding DOWN would put the offset just above the target line's
// top, so scrollDecompose floors to the line ABOVE it. Round UP: for any
// integer s in [V*lh, (V+1)*lh) the decomposed top line is exactly V, and
// ceil(V*lh) is always in that interval (lh > 1).
target := ui.Dp(math.Ceil(float64(vl) * float64(lh)))
if target < 0 {
target = 0
}
if target > TheState.MaxScroll {
target = TheState.MaxScroll
}
return target, true
}
// scrollToFindMatch scrolls so the visual line containing absByte sits at
// the top of the editor viewport (clamped to [0, MaxScroll]), and arms the
// settle (findSettle) that corrects the estimate-based landing once the
// jumped-to window has been shaped.
func scrollToFindMatch(absByte int) {
e := &TheState.Editor
target, ok := findScrollTarget(absByte)
if !ok {
e.Find.SettleByte = -1
return
}
TheState.ScrollOffset = target
if e.Find.Visible {
e.Find.SettleByte = absByte
e.Find.SettleScroll = target
e.Find.SettlePasses = 4 // a few frames of re-correction, then stop
}
}
// findSettle re-runs the search scroll after applyWrapCounts: shaping the
// jumped-to window corrected the visual-line counts around it, so the
// target may now be more accurate than the estimate-based landing. Bounded
// by SettlePasses and cancelled if anything else (user scroll, the
// MaxScroll clamp) moved the viewport in the meantime. Must be called on
// the logic goroutine; returns true when it re-scrolled (caller emits a
// frame).
func (e *EditorState) findSettle() bool {
f := &e.Find
if !f.Visible || f.SettleByte < 0 {
return false
}
if TheState.ScrollOffset != f.SettleScroll {
f.SettleByte = -1
return false
}
target, ok := findScrollTarget(f.SettleByte)
if !ok {
f.SettleByte = -1
return false
}
if target == TheState.ScrollOffset {
f.SettleByte = -1 // converged
return false
}
TheState.ScrollOffset = target
f.SettleScroll = target
f.SettlePasses--
if f.SettlePasses < 0 {
f.SettleByte = -1
}
return true
}
// 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
f.SettleByte = -1 // the edit rebuilds the wrap index: the settled offset is stale
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
}