From 6bd5588ade9a10c2f7388f3ab761683741937611 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 20 Sep 2026 10:48:52 -0400 Subject: [PATCH] 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). --- internal/editor/logic.go | 4 ++++ internal/editor/session.go | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/editor/logic.go b/internal/editor/logic.go index f1e2890..70efed4 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -125,6 +125,10 @@ type Logic struct { // the stat result, and is not a content-arrival signal). restoreScroll ui.Dp 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 // restore line-pin (see refreshRestorePin in session.go): the // line-derived scroll offset is only consistent with the all-estimate diff --git a/internal/editor/session.go b/internal/editor/session.go index b6c41a9..9009431 100644 --- a/internal/editor/session.go +++ b/internal/editor/session.go @@ -258,6 +258,9 @@ func (l *Logic) BeginRestore(s SessionState) { // struct). l.restoreScroll = ui.Dp(s.Scroll) l.restoreScrollArmed = s.Scroll > 0 + if l.restoreScrollArmed { + l.restoreArmedAt = time.Now() + } // 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 { @@ -326,6 +329,19 @@ func (l *Logic) maybeApplyRestoreScroll() bool { if l.state.Page() != EditorPage || !l.restoreContentLanded { 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 // SessionState doc): the snapshot's pixel offset mapped to its position // 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.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 // content, scroll now): lift the save suppression. l.session = SessionState{} @@ -381,6 +398,13 @@ func (l *Logic) maybeApplyRestoreScroll() bool { // the restored window has either settled or the user has moved on. 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 // the viewport). Must be called on the logic goroutine. func (l *Logic) releaseRestorePin() {