Two feature bodies accumulated in the working tree:
1. Pinch to change the app font size, continuously (no snapping):
- internal/ui/pinch_tracker.go: logic-free touch state machine.
Two-mover formation (the resting palm can land first or last;
movement is the only signal valid for both), pair = the mover
pair whose distance changed most, baseline = press distance
(formDist), lazy pending releases, survivor-scroll forwarding
after a pair break. Robust to ~1 fps frames: a whole pinch can
land in one drain (formDist/brokeFactor/lazy releases).
- render.go: pinch probe (raw pointer events) + grab lifecycle so
the pair is exclusive (scroll sees nothing of the pair) and the
survivor's finger keeps working as a scroll after the pinch.
- state.go/logic.go/session.go/frame.go: app-local float font
scale, content-point pin (buffer byte + offset from baseline,
not a layout point, so rewrap keeps the same character under
the center), restore/font pins, session persistence.
- pinch_test.go, pinch_font_test.go, tag_identity_test.go,
real_draw_probe_test.go: unit + real-Renderer/real-Router tests.
2. Soft keyboard must not shift content:
- Root cause: gioui.org/app calls Router.RevealFocus on any frame
the viewport shrinks (IME open under adjustResize) and
synthesizes a pointer.Scroll nudge aimed at the focused field's
stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
content jump.
- Fix: main.go flags the shrink frame; render.go drains that one
synthetic scroll for the gesture's tag before Update (scroll-
range clamping cannot work: the router UNIONs ranges across
frames). Finger scroll (pointer.Drag) and the flinger are
untouched. reveal_focus_drain_test.go reproduces RevealFocus at
the router level and verifies the drain + zero delta.
Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
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).
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).
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.
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).
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).
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.