Pad/internal/editor/session.go
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
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.
2026-08-23 09:00:51 -04:00

519 lines
20 KiB
Go

// State restoration on relaunch (spec §7).
//
// The restorable state is a small snapshot (SessionState) the logic
// goroutine takes on demand: the last opened file, the cursor byte offset,
// the editor scroll offset, the live selection, and the find bar (query,
// open/closed, current match). The search RESULTS themselves are not part
// of the snapshot: on relaunch they are regenerated by re-scanning the
// restored query, and the current match is re-selected by byte offset
// (FindState.Restoring/RestoreMatch), without re-scrolling the restored
// viewport.
//
// Ownership (architecture.md §1): the logic goroutine owns the snapshot
// content. The cmd layer (cmd/pad/main.go) owns the tiny JSON file that
// stores it and registers the writer (Logic.SetSessionSaver); the logic
// calls it rate-limited from emitFrame and unconditionally at Shutdown, so
// the file is current even if the process is killed shortly after a change.
// On startup the cmd layer reads the file and hands it to Logic.BeginRestore
// (before Run, single-threaded), which re-opens the file and shows the
// editor immediately; the cursor/selection/find state land as the file's
// stat and content arrive (handleWorkerResult). A restore whose file is
// gone falls back to the browser page.
package editor
import (
"log"
"math"
"time"
"pad/internal/ui"
)
// sessionSaveInterval rate-limits the periodic session save. The file is
// tiny (a few hundred bytes), so a few writes a second at most is
// negligible. The window bounds how stale the persisted state can be if
// the process is killed (recents-wipe) right after a change: at 1s, a
// kill less than a second after the last edit lost the cursor, selection
// and final scroll. See saveSessionIfChanged for the urgent path.
const sessionSaveInterval = 250 * time.Millisecond
// SessionState is the relaunch snapshot (see the file doc above). All
// positions are absolute file byte offsets; Scroll is in Dp. It is a plain
// comparable value (used with == for change detection).
//
// Scroll is backed by ScrollLine: the pixel offset lives in VISUAL-line
// space (a wrapped line occupies several visual lines, and the scroll-to-
// line mapping runs through the WrapIndex), but on relaunch the counts of
// every line above the restored viewport are still the estimate (1) until
// the line is shaped, and lines above the visible window are never shaped.
// Mapping the saved offset through that fresh index would land a logical
// line DEEPER by all the wrapped continuations above the viewport, with no
// self-correction. The logical line at the viewport top and the sub-line
// remainder are wrap-independent, so they are the restorable unit; Scroll
// is kept as the pre-line-coordinate value (legacy session files).
type SessionState struct {
File string // last opened file ("" = nothing to restore)
Cursor int // cursor byte offset
Scroll float64 // editor scroll offset, Dp
ScrollLine int // logical line at the viewport top (-1 = unknown)
ScrollSub float64 // Scroll's sub-line remainder as a FRACTION of the line height (0 <= f < 1); font-independent. Snapshots written before the pinch feature stored Dp instead; restore converts values > 1.
SelStart int // selection start byte (-1 = no selection)
SelEnd int // selection end byte, exclusive
FindQuery string // find bar query ("" = none)
FindVisible bool // find bar was open
FindCurByte int // start byte of the current find match (-1 = none)
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
AppFontScale float64
}
// SetSessionSaver registers the callback that persists snapshots (the cmd
// layer's JSON file writer). It is invoked from the logic goroutine:
// rate-limited from emitFrame, and unconditionally at Shutdown. The
// callback must be fast (a tiny file write).
func (l *Logic) SetSessionSaver(f func(SessionState)) {
l.sessionSaver = f
}
// SnapshotSession captures the current relaunch snapshot. The cursor is
// clamped to the file length; the selection and current find match are -1/
// -1 when inactive. Must be called on the logic goroutine (or after it has
// fully exited, as Shutdown does).
func (l *Logic) SnapshotSession() SessionState {
s := l.state
e := &s.Editor
cur := e.CursorPosition
if cb := e.ChunkedBuffer; cb != nil {
if fl := int(cb.FileLen()); cur > fl {
cur = fl
}
}
f := &e.Find
curByte := -1
if f.Cur >= 0 && f.Cur < len(f.Matches) {
curByte = f.Matches[f.Cur][0]
}
// Persist the scroll in wrap-independent coordinates (see the
// SessionState doc). The derivation mirrors visibleByteRangePrecise
// (scrollDecompose, then LineForVisual against the CURRENT index), so
// the line is exactly the one the layout is showing at this offset.
scrollLine := -1
var scrollSub float64
if cb := e.ChunkedBuffer; cb != nil && !e.TooLarge {
lh := EffectiveLineHeight()
if gl := e.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
v0, r0 := scrollDecompose(s.ScrollOffset, lh)
scrollLine = int(v0)
if w := cb.WrapIndex; w != nil {
scrollLine = w.LineForVisual(int32(v0))
}
// Store the sub-line remainder as a fraction of the line height so
// it stays valid if the font scale changes between save and restore
// (r0 < lh always, so the fraction is < 1 — that is also the legacy
// Dp discriminator used on restore).
scrollSub = r0 / float64(lh)
}
return SessionState{
File: e.Filename,
Cursor: cur,
Scroll: float64(s.ScrollOffset),
ScrollLine: scrollLine,
ScrollSub: scrollSub,
SelStart: e.SelectionStart,
SelEnd: e.SelectionEnd,
FindQuery: f.Query,
FindVisible: f.Visible,
FindCurByte: curByte,
AppFontScale: float64(s.appFontScale),
}
}
// saveSessionIfChanged persists the snapshot when it differs from the last
// saved one and the rate limit has elapsed. Must be called on the logic
// goroutine (it is called from emitFrame).
//
// While a restore is still pending (l.session set, not yet landed or
// abandoned) saves are suppressed: the pre-land snapshot has zeroed
// cursor/selection, and persisting it would clobber the positions being
// restored if the process is killed during startup.
func (l *Logic) saveSessionIfChanged() {
if l.sessionSaver == nil {
return
}
if l.session.File != "" {
return
}
now := time.Now()
s := l.SnapshotSession()
if s == l.lastSession {
return
}
// Save immediately when the file changes or a selection appears or
// vanishes — low-frequency, high-value changes (the user just opened a
// file or highlighted/cleared text). Everything else (scroll ticks,
// cursor moves, selection-handle drags) is high-frequency and stays
// rate-limited; a kill within the window then loses at most a fraction
// of a second of motion, not the edit state.
urgent := s.File != l.lastSession.File ||
(s.SelStart >= 0) != (l.lastSession.SelStart >= 0)
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
l.writeSession(s, now)
}
// flushSessionSave persists the current snapshot now, bypassing the rate
// limit (still honoring the restore-pending suppression: a pre-land
// snapshot would clobber the positions being restored). Must be called on
// the logic goroutine.
func (l *Logic) flushSessionSave() {
if l.sessionSaver == nil || l.session.File != "" {
return
}
s := l.SnapshotSession()
if s == l.lastSession {
return
}
l.writeSession(s, time.Now())
}
// writeSession is the shared persist tail: hand the snapshot to the cmd
// layer's saver and record it as the new baseline. Must be called on the
// logic goroutine.
func (l *Logic) writeSession(s SessionState, now time.Time) {
l.sessionSaver(s)
l.lastSession = s
l.lastSessionSave = now
}
// FlushSession requests an immediate session persist from any goroutine.
// The Android activity's onStop (recents-wipe, app switch) calls it via
// JNI: the OS gives no user-space hook for the subsequent process kill,
// so onStop is the last reliable moment to flush. Buffered delivery means
// the caller never blocks; if an earlier flush is still queued, one
// snapshot is coalesced into it (the state is the same frame's anyway).
func (l *Logic) FlushSession() {
select {
case l.flushSession <- struct{}{}:
default:
}
}
// BeginRestore prepares the state for relaunch restoration: the last file
// is re-opened and the editor page shown immediately. The cursor,
// selection, find state and scroll land as the file's stat/content arrive
// (handleWorkerResult); the scroll offset is applied now and clamped by
// EditorLayout once the line index sets the real MaxScroll. Must be called
// after NewLogic and before Run (the cmd layer does both before starting
// the logic goroutine), so it touches state single-threaded.
func (l *Logic) BeginRestore(s SessionState) {
if s.File == "" {
return
}
// The zero-value ScrollLine (0) is ambiguous: a genuine top-of-file
// snapshot always has Scroll < lineHeight (no line above line 0
// contributes visual lines), so line 0 with a deep offset is an unset
// field (an in-process caller or test built the snapshot without it) —
// fall back to the pixel offset rather than snapping to the top.
if s.ScrollLine == 0 && s.Scroll >= 2*float64(EffectiveLineHeight()) {
s.ScrollLine = -1
}
l.session = s
e := &l.state.Editor
e.Filename = s.File
e.CursorPosition = 0 // the restored cursor lands with the content
e.TooLarge = false
e.TooLargeSize = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
// Fresh per-file find state; the snapshot's query and visibility land
// with the stat (the file must exist first).
e.findReset()
// The scroll offset is armed, not applied: it lands in the emitFrame
// hook, once the layout pass has a trustworthy viewport (see the Logic
// struct).
l.restoreScroll = ui.Dp(s.Scroll)
l.restoreScrollArmed = s.Scroll > 0
// The app-local font scale lands immediately, BEFORE any layout: the
// restore's line-based offset math reads it through EffectiveLineHeight.
if as := s.AppFontScale; as > 0 {
if as < MinAppFontScale {
as = MinAppFontScale
}
if as > MaxAppFontScale {
as = MaxAppFontScale
}
l.state.appFontScale = float32(as)
}
l.state.page = EditorPage
l.state.FocusedElementID = "editor_text"
l.state.justOpenedAt = time.Now()
l.emitFrame() // show the editor page now; the content loads async
}
// abortRestore ends a restore without landing it (the user opened another
// file, the read failed, or the file is too large): clears the pending
// snapshot so session saving resumes (see saveSessionIfChanged). The editor
// state itself is left alone. Must be called on the logic goroutine.
func (l *Logic) abortRestore() {
l.restoreFile = ""
l.restoreScrollArmed = false
l.restoreContentLanded = false
l.restoreScrollLine = -1
l.session = SessionState{}
}
// maybeApplyRestoreScroll lands the armed restore scroll offset once the
// viewport is trustworthy, clamped to the current MaxScroll (the layout
// clamps again if the line index shrinks it later). It is called from
// emitFrame AFTER the layout pass, because that pass is where MaxScroll is
// computed for the current viewport — applying the offset before it (or in a
// pass with an unknown viewport) would hit the one-way clamp and lose the
// offset. On device the first ScaleEvent can precede the size ConfigEvent
// (and both can precede the restored content), so the offset stays armed
// until scale, size and editor content are all known; it is applied on the
// first such emitFrame. restoreContentLanded (not FileLen, which the stat
// result sets before the content arrives) marks that the buffer holds the
// restored file; clearing restoreFile any earlier would make the read
// result's path guard miss the just-loaded file. Returns true if it
// applied. Must be called on the logic goroutine.
func (l *Logic) maybeApplyRestoreScroll() bool {
if !l.restoreScrollArmed {
return false
}
if !l.scaleSeen {
return false
}
if l.state.PixelWidth <= 0 || l.state.scale <= 0 {
return false
}
if l.state.Page() != EditorPage || !l.restoreContentLanded {
return false
}
// Re-derive the offset from the persisted logical line (see the
// SessionState doc): the snapshot's pixel offset mapped to its position
// only while the saving session's WrapIndex was valid. On relaunch the
// counts above the restored viewport are all the estimate until shaped,
// and those lines never are (shaping covers the visible window only),
// so the raw offset would land a line deeper by every wrapped
// continuation above the viewport. The line-based offset lands on the
// saved line regardless of wrap state.
s := l.session
if s.ScrollLine >= 0 {
lh := EffectiveLineHeight()
if gl := l.state.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
// The offset for a logical line is V(L)*lh + sub under the CURRENT
// WrapIndex (V(L) = L while the index is all estimates). Wrapping
// below the line was already corrected before the offset could be
// applied (pre-apply frames shaped the top-of-file window), so
// mapping through V(L) lands on the line under either state.
base := float64(s.ScrollLine)
if cb := l.state.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && s.ScrollLine < cb.WrapIndex.Len() {
base = float64(cb.WrapIndex.VisualsBefore(s.ScrollLine))
}
sub := s.ScrollSub
if sub > 1 {
// Legacy snapshot: the sub-line remainder was stored in Dp, not
// as a fraction. Convert with the current line height (the
// error is a fraction of one sub-line line, at most).
sub = sub / float64(lh)
}
l.restoreScroll = ui.Dp(base*float64(lh) + sub*float64(lh))
// Arm the line-pin (see refreshRestorePin): wrap-count corrections
// landing below this line would shift the mapping and drag the
// viewport off the restored line while the index settles.
l.restoreScrollLine = s.ScrollLine
l.restoreScrollSub = sub // fraction of the line height
l.restorePinDeadline = time.Now().Add(restorePinTimeout)
} else {
l.restoreScrollLine = -1
}
if l.restoreScroll > l.state.MaxScroll {
l.restoreScroll = l.state.MaxScroll
}
l.state.ScrollOffset = l.restoreScroll
l.restoreScrollArmed = false
// The snapshot has fully landed (cursor/selection/find with the
// content, scroll now): lift the save suppression.
l.session = SessionState{}
return true
}
// restorePinTimeout bounds the line-pin: after this long the index around
// the restored window has either settled or the user has moved on.
const restorePinTimeout = 2 * time.Second
// releaseRestorePin clears the restore line-pin (user or search took over
// the viewport). Must be called on the logic goroutine.
func (l *Logic) releaseRestorePin() {
l.restoreScrollLine = -1
}
// refreshRestorePin re-derives the scroll offset from the pinned line under
// the CURRENT WrapIndex (V(L)*lh + sub, the same mapping the layout uses),
// so wrap-count corrections landing below the pinned line cannot drag the
// viewport off it. Releases the pin when the deadline passes. Must be
// called on the logic goroutine, after a wrap correction has been applied.
func (l *Logic) refreshRestorePin() {
if l.restoreScrollLine < 0 {
return
}
if time.Now().After(l.restorePinDeadline) {
l.restoreScrollLine = -1
return
}
cb := l.state.Editor.ChunkedBuffer
if cb == nil || cb.WrapIndex == nil {
return
}
w := cb.WrapIndex
if l.restoreScrollLine >= w.Len() {
return
}
lh := EffectiveLineHeight()
if gl := l.state.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
// No MaxScroll clamp here: the correction that triggered this refresh
// just grew the index, so the layout's MaxScroll (computed before it) is
// stale and would under-clamp the re-derived offset; the layout pass of
// the emitted frame clamps to the fresh value. An edit shrinking the
// file mid-pin is covered the same way.
off := ui.Dp(float64(w.VisualsBefore(l.restoreScrollLine))*float64(lh) + l.restoreScrollSub*float64(lh))
if off != l.state.ScrollOffset {
l.state.ScrollOffset = off
l.emitFrame()
}
}
// fontPinTimeout bounds the pinch font-pin: rewrap corrections keep landing
// for a while after the last pinch frame (shaping lags the font change);
// after this long the user has moved on and the pin stands down.
const fontPinTimeout = 2 * time.Second
// setFontPin arms/updates the pinch font-pin from a pre-change capture
// (see captureContentPin: the glyph under the pinch center + offset from its
// baseline) and applies the IMMEDIATE anchor: the continuous content
// coordinate under the center scaled by the font ratio. Exact until the
// rewrap lands; the shaped-layout feedback (refreshFontPin) then snaps the
// pinned character exactly onto the center. Must be called on the logic
// goroutine.
func (l *Logic) setFontPin(pin contentPin, m float64, ratio float64) {
l.fontPin = pin
l.fontPinM = m
l.fontPinArmed = true
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
s := l.state
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
// No emitFrame here: the caller (input path or debug command) emits the
// frame carrying the new scale and the rescaled offset.
}
// refreshFontPin re-derives the scroll offset from a freshly shaped layout so
// the pinned content point stays under the pinch center (see
// refineContentPin). Runs on every layout feedback while armed: the font
// change's own re-shaping is the first, and rewrap corrections follow it. If
// the pinned byte is not in the shaped window (no layout yet, or the point
// is in a blank margin), the (line, fragment, sub-line) fallback anchor
// stands in. An edit landing since the capture (EditSeq mismatch) or leaving
// the editor page disarms the pin: its byte no longer names the same content.
// No MaxScroll clamp: the layout pass of the emitted frame clamps to the
// fresh value. Must be called on the logic goroutine.
func (l *Logic) refreshFontPin(fb ui.LayoutFeedback) {
if !l.fontPinArmed {
return
}
if time.Now().After(l.fontPinDeadline) {
l.fontPinArmed = false
return
}
s := l.state
if s.page != EditorPage || fb.EditSeq != l.fontPin.EditSeq {
l.fontPinArmed = false
return
}
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
// Skip feedback shaped at a DIFFERENT scale: during a pinch every frame
// changes the scale, so all but the latest feedback carry layouts whose
// LineHeight no longer matches the geometry the pin computes in. A
// stale-scale layout would place the point by the old geometry for one
// frame (a visible jump) before the next corrects it.
if fb.GlyphLayout.LineHeight > 0 &&
math.Abs(float64(fb.GlyphLayout.LineHeight)-float64(EffectiveLineHeight())) > 0.5 {
return
}
old := s.ScrollOffset
vk := -1
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && fb.WindowStartLine >= 0 {
vk = int(cb.WrapIndex.VisualsBefore(fb.WindowStartLine))
}
if l.fontPin.HaveGlyph && vk >= 0 {
if off, ok := refineContentPin(fb.GlyphLayout, vk, fb.WindowStartByte, l.fontPin.Dy, l.fontPinM, l.fontPin.Byte); ok {
s.ScrollOffset = off
} else {
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
}
} else {
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
}
if s.ScrollOffset != old {
l.emitFrame()
}
}
// releaseFontPin disarms the pinch font-pin: the user (a scroll, a debug
// command, a file switch) has taken over the viewport. Must be called on
// the logic goroutine.
func (l *Logic) releaseFontPin() {
l.fontPinArmed = false
}
// applyRestorePositions clamps the restored snapshot's cursor and selection
// to a file of n bytes and applies them. Must be called on the logic
// goroutine.
func (l *Logic) applyRestorePositions(n int) {
s := l.session
e := &l.state.Editor
if c := s.Cursor; c >= 0 {
if c > n {
c = n
}
e.CursorPosition = c
}
if ss, se := s.SelStart, s.SelEnd; ss >= 0 && se > ss {
if se > n {
se = n
}
if ss < se {
e.SelectionAnchor = ss
e.SelectionStart = ss
e.SelectionEnd = se
}
}
}
// abandonRestore gives up on the relaunch restore (the file's stat failed:
// it was deleted or moved since the last session): reset the editor and
// land on the browser page, as if nothing had been restored. Must be called
// on the logic goroutine.
func (l *Logic) abandonRestore() {
l.abortRestore()
e := &l.state.Editor
log.Printf("Logic: cannot restore %q; starting in the browser", e.Filename)
e.Filename = ""
e.ChunkedBuffer = nil
e.CursorPosition = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.Find = FindState{SettleByte: -1}
l.state.page = BrowserPage
l.state.ScrollOffset = 0
l.state.FocusedElementID = ""
}