Commit Graph

6 Commits

Author SHA1 Message Date
0f1b6e6290 Hold the restored scroll on its line while wrap counts settle (spec §2.4)
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).
2026-08-20 21:03:56 -04:00
6968f6a284 Restore scroll by logical line, not pixel offset (wrap-aware)
On device: scroll far down a wrapped file, relaunch, and the app lands
further DOWN than where the user left off — the deeper the scroll, the
further off.

Root cause: the persisted Scroll is a pixel offset in VISUAL-line space.
Restoring maps it through the WrapIndex (scrollDecompose -> LineForVisual),
but on relaunch every count is the estimate (1) until the line is shaped,
and shaping covers the visible window only — the lines ABOVE the restored
viewport are never shaped. With all-ones counts LineForVisual maps the
offset 1:1, landing a logical line deeper by every wrapped continuation
above the viewport, and the state is stable (the under-counted lines never
re-enter the window), so it never self-corrects.

The snapshot now persists wrap-independent coordinates: the logical line
at the viewport top (derived with the same mapping the layout uses,
against the current index, so it is exactly the shown line) plus the
sub-line remainder. The restore re-derives the offset as line*lh + sub,
which maps to the saved line under any wrap state (all-ones or populated).
The raw Dp offset is kept for pre-line-coordinate session files
(loadSession defaults the missing key to -1; BeginRestore rejects the
ambiguous zero value: a genuine line-0 snapshot always has Scroll < lh).

TestRestore_ScrollSurvivesWrapState reproduces it: 150 lines recorded as
wrapped x3, viewport at logical line 200 (visual 500); a relaunch with a
fresh WrapIndex must land the window on line 200. Fails pre-fix (window at
line 254, i.e. deeper) and passes with the fix.

Docs: spec §2.4 (line-based scroll persist + current save policy),
architecture §6.7 (why the offset is unrestorable by re-mapping).
2026-08-20 19:31:51 -04:00
2bdff5c8e8 Flush the session snapshot on activity onStop (Android)
The OS provides no user-space hook for a process kill, but the
activity onStop fires on every 'going away' transition the framework
still controls: entering recents (the swipe-wipe path), app switch,
and home. Recents-wipe then kills the process right after onStop
returns, so that moment is the last reliable flush.

- Logic.FlushSession (any goroutine, buffered, non-blocking) ->
  flushSessionSave on the owner: persist the snapshot now, bypassing
  the rate limit (still honoring the restore-pending suppression).
  saveSessionIfChanged's persist tail is deduplicated into writeSession.
- JNI: GioActivity.onStop (patched into the smali by the build
  scripts, in sync) now calls the static native padFlushSession, which
  maps to the pad_flush_session cgo export.

Verified on emulator: the flush fires on home/app-switch and when the
app is backgrounded into recents before a kill; a state change made
inside the 250ms rate window and then backed out of the app persists
the latest position. A hard kill while foregrounded (force-stop,
memory pressure) still has no hook — the immediate edit saves plus the
250ms rate window bound that loss.
2026-08-20 18:24:43 -04:00
2eb18b5c70 Persist edits immediately so a fast app-kill loses no state
Reproduced on device: open a file, scroll, long-press-highlight a
word, then recents-wipe the app ~1s after the highlight. The session
file held the file but lost the selection and cursor: the 1s
rate-limit had not elapsed since the previous save, so the final
changes never flushed before the process died (a recents-wipe is a
kill, not a clean shutdown, so Shutdown's final save never runs).

Two changes in saveSessionIfChanged:

- Urgent path: save immediately when the file changes or a selection
  appears/vanishes. These are low-frequency, high-value changes
  (the user just opened a file or highlighted/cleared text), and they
  are exactly what 'where I left off' means.
- sessionSaveInterval 1s -> 250ms: bounds how stale scroll/cursor can
  be at an unlucky kill. The file is a few hundred bytes, so a few
  small writes a second during active scrolling is negligible.

TestRestore_UrgentSaveOnSelection drives the kill race: a selection
made right after the file-open save must land in the saver without
waiting out the interval (fails with the urgent path disabled).
2026-08-20 18:06:29 -04:00
03595af27d Fix relaunch-restore bugs found on device (spec §7 verification)
On-device runs exposed three bugs the e2e suite could not (it never
feeds layout feedback, and runs headless without size events):

1. WrapIndex poisoning from zero-width shapes. The first frames are
   built before the window size is known (px=0x0); the renderer shapes
   the editor window at zero width, where every line wraps into many
   visual lines. When that feedback arrives, applyWrapCounts writes the
   inflated counts to the window's lines. Normally the app stays on
   those lines and re-shapes them at a real width, which corrects the
   counts before anyone notices. A restored scroll moves the viewport
   away instead, so the poisoned counts persist and map the restored
   scroll offset to the wrong line (2000 landed on line 11 of 200).
   The frame now carries ViewportDegenerate (set at frame-build time,
   since feedback delivery lags shaping by a frame), and the main loop
   drops layout feedback for such frames.

2. Session saving suppressed forever after a successful restore.
   saveSessionIfChanged suppresses saves while l.session is set, and
   only abortRestore cleared it — the success path never did, so an
   app restored from a session never persisted new state. The
   snapshot has fully landed once the cursor/selection/find have
   landed with the content and the armed scroll has landed (or there
   was none); clear l.session at both points.

3. gofmt on restore_test.go (comment alignment).
2026-08-20 17:31:55 -04:00
e3690d6b98 Restore last file, cursor, scroll, selection and find state on relaunch (spec §7)
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.
2026-08-20 16:01:02 -04:00