On-device runs exposed three bugs the e2e suite could not (it never feeds layout feedback, and runs headless without size events): 1. WrapIndex poisoning from zero-width shapes. The first frames are built before the window size is known (px=0x0); the renderer shapes the editor window at zero width, where every line wraps into many visual lines. When that feedback arrives, applyWrapCounts writes the inflated counts to the window's lines. Normally the app stays on those lines and re-shapes them at a real width, which corrects the counts before anyone notices. A restored scroll moves the viewport away instead, so the poisoned counts persist and map the restored scroll offset to the wrong line (2000 landed on line 11 of 200). The frame now carries ViewportDegenerate (set at frame-build time, since feedback delivery lags shaping by a frame), and the main loop drops layout feedback for such frames. 2. Session saving suppressed forever after a successful restore. saveSessionIfChanged suppresses saves while l.session is set, and only abortRestore cleared it — the success path never did, so an app restored from a session never persisted new state. The snapshot has fully landed once the cursor/selection/find have landed with the content and the armed scroll has landed (or there was none); clear l.session at both points. 3. gofmt on restore_test.go (comment alignment).
242 lines
8.5 KiB
Go
242 lines
8.5 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 one write a second at most is negligible.
|
|
const sessionSaveInterval = time.Second
|
|
|
|
// 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).
|
|
type SessionState struct {
|
|
File string // last opened file ("" = nothing to restore)
|
|
Cursor int // cursor byte offset
|
|
Scroll float64 // editor scroll offset, Dp
|
|
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]
|
|
}
|
|
return SessionState{
|
|
File: e.Filename,
|
|
Cursor: cur,
|
|
Scroll: float64(s.ScrollOffset),
|
|
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()
|
|
if now.Sub(l.lastSessionSave) < sessionSaveInterval {
|
|
return
|
|
}
|
|
s := l.SnapshotSession()
|
|
if s == l.lastSession {
|
|
return
|
|
}
|
|
l.sessionSaver(s)
|
|
l.lastSession = s
|
|
l.lastSessionSave = now
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
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 = ""
|
|
}
|