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).
345 lines
13 KiB
Go
345 lines
13 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"
|
|
"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, Dp (0 <= r < lineHeight)
|
|
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)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
scrollSub = r0
|
|
}
|
|
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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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.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.
|
|
if s := l.session; s.ScrollLine >= 0 {
|
|
lh := EffectiveLineHeight()
|
|
if gl := l.state.Editor.GlyphLayout; gl.LineHeight > 0 {
|
|
lh = gl.LineHeight
|
|
}
|
|
l.restoreScroll = ui.Dp(float64(s.ScrollLine)*float64(lh) + s.ScrollSub)
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 = ""
|
|
}
|