The renderer kept a mirror of the pushed IME snippet (the 'IME model') to translate commit positions, but it transiently desynced from the buffer on fling/tap sequences (observed as a few-byte mapping drift on both the x86_64 emulator and the ARM phone), corrupting text. The model string also sat on the main goroutine next to the JNI render path, where the app observed states that were impossible for Go memory (string contents changing between reads microseconds apart), pointing at corruption in the native bridge layer. Restructure along the lines of the Android InputConnection contract and Gio's own reference editor (widget/editor.go): - Commits carry absolute file runes (the pushed snippet's coordinate space) straight to the logic goroutine, which maps them to bytes against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan). Scrolling moves the window, not the buffer, so the mapping is exact mid-fling by construction — no mirror to desync. - Drift guard in HandleIMECommit: a small commit (range <= 2 runes) is always anchored at the caret the IME was last told about; if the IME reports it ending elsewhere, its snippet text is stale (a dropped restartInput, as Gboard does during flings) and its position is in the stale text's coordinates — snap the commit to the cursor, the only position it cannot drift from. - FlushIME simplifies to: push the snippet when the frame's (context+window) text differs from the last push (gioui dedupes against its own cache), force the selection re-push in the same frame. After a commit the frame text equals what the IME already holds locally, so the restart is naturally suppressed; a fling re-anchors the IME once per text change. - Remove the renderer model (adoptFrame/ModelTranslate/ ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the window-relative imeRuneToByte. Also fixed along the way (both found while chasing the corruption): - real.ReadFileAt: loop over short reads. A single ReadAt on Android FUSE can return a short read, silently truncating a chunk and shifting every byte offset after it. - logic: a late lazy-chunk result no longer clobbers a buffer that SetContent has already fully loaded. - e2e: large-file IME test (1.6 MB file, fling + commit). - app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio builds the mipmap/adaptive icon set. Verified: go vet + staticcheck, go test -race (all packages), and the emulator scenario loop (open moby excerpt, fling to mid-file, tap, type 'a', byte-compare the saved file) 75/75 clean.
112 lines
4.5 KiB
Go
112 lines
4.5 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
|
|
// AppFontScale is the app-local pinch font scale (1.0 = default, 0 =
|
|
// not set yet). The renderer multiplies the editor font size by it;
|
|
// FontScale is already folded into gtx.Metric (PxPerSp).
|
|
AppFontScale float32
|
|
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
|
|
// ScrollOffset is the editor scroll offset this frame's elements laid
|
|
// out at, shipped with the shaped glyph layout (LayoutFeedback) so the
|
|
// logic can express layout positions in content coordinates.
|
|
ScrollOffset ui.Dp
|
|
// 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,
|
|
AppFontScale: l.state.appFontScale,
|
|
FocusedElementID: l.state.FocusedElementID,
|
|
Query: l.state.Browser.Query,
|
|
FindQuery: l.state.Editor.Find.Query,
|
|
FindClearSeq: l.state.Editor.Find.ClearSeq,
|
|
ScrollOffset: l.state.ScrollOffset,
|
|
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
|
|
}
|
|
}
|