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).
101 lines
4.0 KiB
Go
101 lines
4.0 KiB
Go
package editor
|
|
|
|
import (
|
|
"time"
|
|
|
|
"pad/internal/ui"
|
|
)
|
|
|
|
// Frame is the unit of handoff from the logic goroutine to the frame
|
|
// receiver. Per architecture.md §9, the frame is the ONLY cross-goroutine
|
|
// state carrier: the main goroutine must never read *State directly, it
|
|
// reads the snapshot stored here by the frame receiver (under the frame
|
|
// mutex).
|
|
//
|
|
// Elems is the computed element tree for the next draw.
|
|
// The remaining fields are the view-state snapshot that the main goroutine
|
|
// needs to route events and forward input:
|
|
//
|
|
// - Scale: current px-per-Dp, so the main goroutine can detect scale
|
|
// changes and pass the scale to the renderer's Draw call.
|
|
// - FocusedElementID: which registered element receives key/edit events.
|
|
// - Query: the search query the logic goroutine is currently filtering
|
|
// with; the main goroutine compares it against the (main-owned) search
|
|
// widget's text and forwards changes via SearchQueryChan.
|
|
type Frame struct {
|
|
Elems []ui.Element
|
|
Scale float32
|
|
FontScale float32 // user font-size setting the logic bookkeeping used
|
|
FocusedElementID string
|
|
Query string
|
|
// FindQuery: the in-file search query the logic goroutine has processed
|
|
// (mirrors EditorState.Find.Query). Main compares it against the
|
|
// "find_bar" widget's text and forwards changes via FindQueryChan — the
|
|
// same contract as Query/searchQueryChan.
|
|
FindQuery string
|
|
// FindClearSeq mirrors EditorState.Find.ClearSeq: main wipes the widget
|
|
// input once per NEW value (the X button cleared the logic-side query).
|
|
FindClearSeq int
|
|
// WindowStartByte / WindowStartLine / EditSeq: the editor window this
|
|
// frame's elements describe. The main goroutine forwards them with the
|
|
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply
|
|
// the layout's wrap counts to exactly the lines it was shaped for, and
|
|
// drop them if an edit landed in the meantime.
|
|
WindowStartByte int
|
|
WindowStartLine int // -1 when the frame has no editor window
|
|
WindowText string // the editor window this frame's text element holds
|
|
EditSeq uint64
|
|
// ViewportDegenerate is set when this frame was built before the
|
|
// window's pixel size was known (0x0). Its editor window, if shaped at
|
|
// all, was shaped at zero width: the shaper wraps every line into many
|
|
// visual lines, and feeding those counts back (LayoutFeedback) would
|
|
// poison the WrapIndex for the window's lines. The main goroutine
|
|
// drops the feedback for such frames.
|
|
ViewportDegenerate bool
|
|
}
|
|
|
|
// frameOf wraps a computed element tree with the current view-state
|
|
// snapshot. Must be called on the logic goroutine.
|
|
func (l *Logic) frameOf(elems []ui.Element) Frame {
|
|
return Frame{
|
|
Elems: elems,
|
|
Scale: l.state.scale,
|
|
FontScale: l.state.fontScale,
|
|
FocusedElementID: l.state.FocusedElementID,
|
|
Query: l.state.Browser.Query,
|
|
FindQuery: l.state.Editor.Find.Query,
|
|
FindClearSeq: l.state.Editor.Find.ClearSeq,
|
|
WindowStartByte: l.state.Editor.IMEWindowStartByte,
|
|
WindowStartLine: l.state.WindowStartLine,
|
|
WindowText: l.state.Editor.IMEWindowText,
|
|
EditSeq: l.state.Editor.EditSeq,
|
|
ViewportDegenerate: l.state.PixelWidth <= 0 || l.state.PixelHeight <= 0,
|
|
}
|
|
}
|
|
|
|
// inspectReq is a test-only request to run fn on the logic goroutine.
|
|
// It preserves the single-owner invariant (architecture.md §1): the fn
|
|
// executes on the owner, not on the caller. fn must not block on sends to
|
|
// logic channels.
|
|
type inspectReq struct {
|
|
fn func(st *State) any
|
|
resp chan any
|
|
}
|
|
|
|
// Inspect runs fn on the logic goroutine and returns its result. It is
|
|
// intended for tests; production code must use the regular channels.
|
|
func (l *Logic) Inspect(fn func(st *State) any) (any, bool) {
|
|
req := &inspectReq{fn: fn, resp: make(chan any, 1)}
|
|
select {
|
|
case l.inspectChan <- req:
|
|
case <-time.After(5 * time.Second):
|
|
return nil, false
|
|
}
|
|
select {
|
|
case v := <-req.resp:
|
|
return v, true
|
|
case <-time.After(5 * time.Second):
|
|
return nil, false
|
|
}
|
|
}
|