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() {