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).
Persist a tiny JSON snapshot (SessionState) written by the cmd layer
($HOME/.pad/session.json off-Android, /storage/emulated/0/Pad/ on
Android) and call the logic-owned snapshot rate-limited (<=1/s, on
change) from emitFrame plus unconditionally at Shutdown. On launch the
cmd layer hands the snapshot to Logic.BeginRestore before Run; the
file re-opens through the normal openFile path and lands straight on
the editor page.
- Cursor/selection land with the content, clamped to a shrunk file
(path-guarded so a late result for a replaced file cannot apply the
snapshot to the wrong buffer); a missing file falls back to the
browser.
- Scroll is applied only after the first ScaleEvent has been laid out:
the size ConfigEvent precedes it and the one-way MaxScroll clamp in a
wrong-unit layout would corrupt the offset (found by e2e).
- Find: query + open/closed + current match are persisted; results are
regenerated by re-scanning and the saved current match is re-selected
by byte offset (Find.Restoring/RestoreMatch) without re-scrolling the
restored viewport. A closed-bar query re-scans on the next bar open
instead (an eager scan would be dropped and leave Scanning stuck).
- The main-owned find_bar widget is seeded with the restored query so
its first frame matches the logic-side query.
Docs: spec.md gains §2.4 and drops the §7 row; invariant 5 updated;
architecture.md gains §6.7. Tests: 7 e2e tests covering cursor/scroll/
selection restore, clamping, missing-file fallback, find-bar restore
(open/closed), and the saver/shutdown persist paths.
The main-loop mirror of the X-button clear was level-triggered:
'FindQuery=="" && widget has text'. But the widget updates on every
keystroke while the logic only stores the query a round-trip later, so any
frame drawn in that window still carried FindQuery=="" and wiped the input
- the periodic self-clearing.
Make it edge-triggered: FindState.ClearSeq bumps once per clear; the frame
carries it as FindClearSeq; main wipes the widget input once per NEW value
(lastFindClearSeq handshake). findClear bumps it; findReset preserves it
(main tracks it monotonically, so a reset to 0 would swallow a later clear).
Tests: unit asserts the bump, monotonicity, and close/reopen preservation.
The X was redundant with the top-bar search icon (both closed the bar).
Now the X empties the query and results but leaves the bar open; closing
is the search icon's toggle.
- editor: new findClear (empties query/matches, bumps Gen so an in-flight
scan of the old query is dropped, disarms the settle) and FindClear
handler; the X icon now runs FindClear.
- main: mirrors the logic-side clear into the main-owned widget input
(frame.FindQuery=="" while the widget still has text -> SetText("")).
- tests: unit findClear (stays open, supersedes in-flight scan); e2e X
clears but keeps the bar open and focus, close now via ToggleFind.
- doc: spec updated (clear button vs icon toggle).
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.
- pool: SearchTask + FindSubstring (case-insensitive substring scan with
query generation for stale-result dropping)
- editor: FindState on EditorState; find bar handlers (ToggleFind/FindNext/
FindPrev/FindClose); worker-pool scan of the in-memory content; edit-aware
offset remapping (shift after, drop overlapping); next/prev with
wrap-around, selection + scroll to match; main-owned find_bar input
forwarded via FindQueryChan (browser search_bar precedent)
- ui: find-bar layout below the margin-free top bar; search icon on the top
bar right; new search/close/chevron_up/chevron_down icons
- logic: findQueryChan, TypeSearch result handling, findReset on open
- frame: FindQuery field (main syncs the widget text like Query)
- tests: pool scan tests, find-state unit tests (remap, nav, gen gate,
focus), e2e find bar + navigation + edit-survival on real files
- docs: spec/architecture/development_plan updated (search implemented,
channel/task tables)