editor: don't clamp the restored scroll offset against an unmeasured MaxScroll

maybeApplyRestoreScroll applied the offset as soon as the content had
landed, then clamped it to MaxScroll. For a string buffer (small file)
MaxScroll derives from the shaper's last baseline, which is still 0 at
that point - nothing has shaped yet - so every restored offset was
clamped to 0 and the scroll position was silently lost on relaunch
(the line-pin that re-derives the offset had already been armed from
the zeroed value).

Wait for the first shaped frame (its feedback triggers the emitFrame
that re-runs the apply; a chunked buffer's MaxScroll is measured from
the line index and needs no such wait). A deadline bounds the wait so
content that never produces a baseline (empty, newlines-only) cannot
leave the restore armed forever - it lands best-effort.

Verified on the emulator: 120-line file, scroll to line 75, relaunch -
lands at line 75 (log: RESTORE scroll off=1272.7 line=75
maxScroll=1272.7; before the fix: off=0.0 maxScroll=0.0).
This commit is contained in:
Greg Pomerantz 2026-09-20 10:48:52 -04:00
parent 7a6b130078
commit 6bd5588ade
2 changed files with 28 additions and 0 deletions

View File

@ -125,6 +125,10 @@ type Logic struct {
// the stat result, and is not a content-arrival signal). // the stat result, and is not a content-arrival signal).
restoreScroll ui.Dp restoreScroll ui.Dp
restoreScrollArmed bool restoreScrollArmed bool
// restoreArmedAt is when the restore scroll was armed; bounds how long
// maybeApplyRestoreScroll may wait for a trustworthy MaxScroll (see
// there) before landing best-effort.
restoreArmedAt time.Time
// restoreScrollLine/restoreScrollSub/restorePinDeadline implement the // restoreScrollLine/restoreScrollSub/restorePinDeadline implement the
// restore line-pin (see refreshRestorePin in session.go): the // restore line-pin (see refreshRestorePin in session.go): the
// line-derived scroll offset is only consistent with the all-estimate // line-derived scroll offset is only consistent with the all-estimate

View File

@ -258,6 +258,9 @@ func (l *Logic) BeginRestore(s SessionState) {
// struct). // struct).
l.restoreScroll = ui.Dp(s.Scroll) l.restoreScroll = ui.Dp(s.Scroll)
l.restoreScrollArmed = s.Scroll > 0 l.restoreScrollArmed = s.Scroll > 0
if l.restoreScrollArmed {
l.restoreArmedAt = time.Now()
}
// The app-local font scale lands immediately, BEFORE any layout: the // The app-local font scale lands immediately, BEFORE any layout: the
// restore's line-based offset math reads it through EffectiveLineHeight. // restore's line-based offset math reads it through EffectiveLineHeight.
if as := s.AppFontScale; as > 0 { if as := s.AppFontScale; as > 0 {
@ -326,6 +329,19 @@ func (l *Logic) maybeApplyRestoreScroll() bool {
if l.state.Page() != EditorPage || !l.restoreContentLanded { if l.state.Page() != EditorPage || !l.restoreContentLanded {
return false return false
} }
// The clamp below needs a trustworthy MaxScroll. For a chunked buffer it
// is measured from the line index (or the large pre-index estimate) and
// is valid as soon as the content has landed. For a string buffer it
// derives from the shaper's last baseline, which is still 0 until the
// first non-empty frame has shaped — applying now would clamp the
// offset to 0 and the scroll position would be silently lost. Wait for
// that frame (its feedback triggers the emitFrame that re-runs this);
// a deadline covers content that never produces a baseline (empty or
// newlines-only), which lands best-effort.
if l.state.Editor.ChunkedBuffer == nil && l.state.LastLineY <= 0 &&
time.Since(l.restoreArmedAt) < restoreScrollArmTimeout {
return false
}
// Re-derive the offset from the persisted logical line (see the // Re-derive the offset from the persisted logical line (see the
// SessionState doc): the snapshot's pixel offset mapped to its position // SessionState doc): the snapshot's pixel offset mapped to its position
// only while the saving session's WrapIndex was valid. On relaunch the // only while the saving session's WrapIndex was valid. On relaunch the
@ -371,6 +387,7 @@ func (l *Logic) maybeApplyRestoreScroll() bool {
} }
l.state.ScrollOffset = l.restoreScroll l.state.ScrollOffset = l.restoreScroll
l.restoreScrollArmed = false l.restoreScrollArmed = false
log.Printf("RESTORE scroll off=%.1f line=%d maxScroll=%.1f", l.restoreScroll, s.ScrollLine, l.state.MaxScroll)
// The snapshot has fully landed (cursor/selection/find with the // The snapshot has fully landed (cursor/selection/find with the
// content, scroll now): lift the save suppression. // content, scroll now): lift the save suppression.
l.session = SessionState{} l.session = SessionState{}
@ -381,6 +398,13 @@ func (l *Logic) maybeApplyRestoreScroll() bool {
// the restored window has either settled or the user has moved on. // the restored window has either settled or the user has moved on.
const restorePinTimeout = 2 * time.Second const restorePinTimeout = 2 * time.Second
// restoreScrollArmTimeout bounds how long maybeApplyRestoreScroll may wait
// for a trustworthy MaxScroll before landing the offset best-effort (see
// the guard there). Shaping a real file completes in well under this; it
// exists so degenerate content (empty, newlines-only) cannot leave the
// restore armed forever.
const restoreScrollArmTimeout = 2 * time.Second
// releaseRestorePin clears the restore line-pin (user or search took over // releaseRestorePin clears the restore line-pin (user or search took over
// the viewport). Must be called on the logic goroutine. // the viewport). Must be called on the logic goroutine.
func (l *Logic) releaseRestorePin() { func (l *Logic) releaseRestorePin() {