// 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.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)) } l.restoreScroll = ui.Dp(base*float64(lh) + s.ScrollSub) // 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 = float64(s.ScrollSub) 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) if off != l.state.ScrollOffset { l.state.ScrollOffset = off l.emitFrame() } } // 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 = "" }