On device (Pixel 9 Pro) the relaunch landed further UP than the saved position: while the restore scroll is still armed (scale + size + content can take ~600 ms), the top-of-file window is what gets rendered, and its shaping feedback — real wrap counts for lines ABOVE the restored line — lands before or just after the offset is applied. The counts are correct data, but they change V(line), so the line-derived offset (synthesized for the all-estimate index) maps to a shallower line and the viewport drifts up; the save then persists the drifted line and every subsequent relaunch lands there. Fix: the restore pins the logical line. Until the restored window's own shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps), every accepted wrap correction re-derives the offset as VisualsBefore(line)*lh + sub under the corrected index. The pin refresh does not clamp to MaxScroll: the correction just grew the index, so the pre-layout clamp value is stale and would under-clamp the re-derived offset (the layout of the emitted frame clamps to the fresh value). Also: the apply-time offset is mapped through VisualsBefore under the current index (identical to line*lh while the index is all estimates), so pre-apply corrections are absorbed instead of gated away. Verified on device: saved line 420 -> restored 420 (was 333); saved 573 (near EOF, clamp territory) -> restored 573. New e2e regression TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late top-window feedback and fails pre-fix (window drifts 200 -> 66).
437 lines
14 KiB
Go
437 lines
14 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
|
|
|
|
// ClearSeq bumps once per clear (the bar's X button, findClear). Main
|
|
// mirrors each NEW value into the main-owned widget input exactly once —
|
|
// an edge-triggered handshake, deliberately NOT a level check: a
|
|
// "FindQuery==\"\" && the widget has text" level check would also fire on
|
|
// any stale frame between the user's typing (widget updates immediately)
|
|
// and the logic storing it (a round-trip away), wiping the input while
|
|
// typing.
|
|
ClearSeq int
|
|
|
|
// --- Relaunch restoration (spec §7, see session.go) ---
|
|
// Restoring is set while a restored session's find scan is in flight:
|
|
// its first result selects the restored current match (RestoreMatch) but
|
|
// does NOT scroll — the viewport stays where the restored scroll offset
|
|
// put it. applySearchResult consumes both; findReset drops them.
|
|
Restoring bool
|
|
// RestoreMatch is the byte offset of the pre-launch current find match
|
|
// (the snapshot's FindCurByte); -1 = none.
|
|
RestoreMatch 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) }
|
|
|
|
// FindClear is the tap handler of the find bar's X button: it empties the
|
|
// query (and the results) but leaves the bar open. Closing the bar is the
|
|
// search icon's toggle (ToggleFind). The main-owned widget's text is
|
|
// mirrored to empty by the main loop (frame.FindQuery == "" while the
|
|
// widget still has text, see cmd/pad/main.go).
|
|
func FindClear(data any) { TheState.Editor.findClear() }
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// findClear empties the query and the results (find bar X button). Gen is
|
|
// bumped so an in-flight scan of the old query is dropped when it lands.
|
|
func (e *EditorState) findClear() {
|
|
f := &e.Find
|
|
f.Query = ""
|
|
f.Matches = nil
|
|
f.Cur = -1
|
|
f.Scanning = false
|
|
f.Gen++
|
|
f.ClearSeq++
|
|
f.SettleByte = -1
|
|
}
|
|
|
|
// 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.
|
|
// ClearSeq is preserved: main tracks it monotonically, so resetting it
|
|
// to 0 would make a later clear (seq 1) indistinguishable from an old
|
|
// one already mirrored.
|
|
e.Find = FindState{Query: e.Find.Query, Gen: e.Find.Gen + 1, ClearSeq: e.Find.ClearSeq, 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
|
|
}
|
|
}
|
|
// Relaunch restore: select the pre-launch current match by byte
|
|
// offset (spec §7) — the one containing the byte, else the first
|
|
// match after it, else the last (its text may have been edited
|
|
// away, so a fallback is always defined).
|
|
if f.Cur < 0 && f.RestoreMatch >= 0 {
|
|
i := sort.Search(len(f.Matches), func(i int) bool {
|
|
return f.Matches[i][0] > f.RestoreMatch
|
|
})
|
|
switch {
|
|
case i > 0 && f.Matches[i-1][1] > f.RestoreMatch:
|
|
f.Cur = i - 1
|
|
case i < len(f.Matches):
|
|
f.Cur = i
|
|
default:
|
|
f.Cur = len(f.Matches) - 1
|
|
}
|
|
}
|
|
if f.Cur < 0 {
|
|
f.Cur = 0
|
|
}
|
|
// The first discovery of matches (matches went 0 -> N) selects the
|
|
// current match and scrolls the view to it — except during a
|
|
// relaunch restore (Restoring), where the viewport must stay where
|
|
// the restored scroll offset put it. 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])
|
|
if !f.Restoring {
|
|
scrollToFindMatch(f.Matches[f.Cur][0])
|
|
}
|
|
}
|
|
}
|
|
f.Restoring = false
|
|
f.RestoreMatch = -1
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if TheLogic != nil {
|
|
TheLogic.releaseRestorePin() // search takes over the viewport
|
|
}
|
|
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
|
|
}
|