IME: map commits against the whole buffer; drop the renderer-side model

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.
This commit is contained in:
Greg Pomerantz 2026-09-13 11:57:38 -04:00
parent 4c8a13b779
commit f54ca2f81a
15 changed files with 1096 additions and 188 deletions

BIN
cmd/pad/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@ -371,10 +371,27 @@ func run(w *app.Window) error {
})
}
case key.EditEvent:
if focusedID == "editor_text" {
// The commit range is in absolute file runes (the
// coordinate space of the pushed snippet, whose
// Range.Start is the context start). The logic maps
// it to bytes against the whole buffer — exact
// even mid-fling — and applies the drift guard
// (see editor.HandleIMECommit).
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: k,
Handler: editor.HandleIMECommit,
Data: editor.IMECommit{
StartRune: k.Range.Start,
EndRune: k.Range.End,
Text: k.Text,
},
})
break
}
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: k,
})
case key.SnippetEvent:
// Handle snippet event if necessary, or ignore
case key.FocusEvent:
@ -387,8 +404,22 @@ func run(w *app.Window) error {
}
}
}
// Flush the IME snippet/selection for the focused editor field
// (see Renderer.FlushIME): after the key events above were drained
// and mirrored into the IME model, and before e.Frame below flushes
// the ops to the OS (which is when gioui compares the pushed state
// against the IME's own state.
if focusedID == "editor_text" {
for _, el := range frame.Elems {
if tf, ok := el.(ui.TextField); ok && tf.ID() == "editor_text" {
renderer.FlushIME(gtx, tf)
break
}
}
}
e.Frame(&ops)
mu.Unlock()
if newScale != curScale || newFontScale != curFontScale {
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale, FontScale: newFontScale}
}

2
go.mod
View File

@ -5,6 +5,7 @@ go 1.24.2
require (
gioui.org v0.10.2
golang.org/x/image v0.26.0
golang.org/x/sys v0.39.0
)
require (
@ -12,6 +13,5 @@ require (
github.com/go-text/typesetting v0.3.4 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

View File

@ -60,6 +60,7 @@ type Frame struct {
// 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

View File

@ -209,17 +209,21 @@ func TestHandleReplaceRange_SwappedBounds(t *testing.T) {
}
// TestHandleReplaceRange_WindowOffset_Chunked verifies the scrolled-viewport
// path: the IME snippet is a window that does NOT start at buffer byte 0, so
// the EditEvent.Range is window-relative and must be offset by the window
// start to address the buffer.
// path: the IME snippet starts mid-file, so the EditEvent.Range (absolute
// file runes, addressed from IMEOffsetRune) must be mapped through the
// leading context and the window to address the buffer.
func TestHandleReplaceRange_WindowOffset_Chunked(t *testing.T) {
newChunkedState(t, "Hello World, this is a test.", 8)
// Simulate a scrolled viewport: layout would have set the visible window
// to start at byte 6 ("World, this is a test.").
// to start at byte 6 ("World, this is a test."), with no leading context
// (all ASCII, so rune offset == byte offset).
TheState.Editor.IMEWindowStartByte = 6
TheState.Editor.IMEWindowText = "World, this is a test."
// Replace runes [0,5) of the window ("World") with "There".
HandleReplaceRange(0, 5, "There")
TheState.Editor.IMEContext = ""
TheState.Editor.IMEContextStartByte = 6
TheState.Editor.IMEOffsetRune = 6
// The IME reports absolute file runes: "World" is runes [6,11).
HandleReplaceRange(6, 11, "There")
cb := TheState.Editor.ChunkedBuffer
got, err := cb.FullContent()
if err != nil {
@ -234,17 +238,110 @@ func TestHandleReplaceRange_WindowOffset_Chunked(t *testing.T) {
}
}
// TestHandleReplaceRange_WindowOffset_String verifies the same window-relative
// logic on the small-file (string) buffer.
// TestHandleReplaceRange_WindowOffset_String verifies the same absolute-rune
// mapping on the small-file (string) buffer.
func TestHandleReplaceRange_WindowOffset_String(t *testing.T) {
st := newStringState("Hello World, this is a test.")
st.Editor.IMEWindowStartByte = 6
st.Editor.IMEWindowText = "World, this is a test."
HandleReplaceRange(0, 5, "There")
st.Editor.IMEContext = ""
st.Editor.IMEContextStartByte = 6
st.Editor.IMEOffsetRune = 6
HandleReplaceRange(6, 11, "There")
if want := "Hello There, this is a test."; st.Editor.Buffer != want {
t.Fatalf("windowed replace: got %q, want %q", st.Editor.Buffer, want)
}
if cp := st.Editor.CursorPosition; cp != 11 {
if cp := TheState.Editor.CursorPosition; cp != 11 {
t.Fatalf("cursor = %d, want 11", cp)
}
}
// TestHandleReplaceRange_WithContext verifies the leading-context mapping:
// the snippet is context+window and the IME's absolute rune offsets may land
// inside the context (before the window). The context contains a multi-byte
// rune so rune != byte there, which pins the rune->byte walk. A second, fresh
// state covers a range that spans the context/window boundary.
func TestHandleReplaceRange_WithContext(t *testing.T) {
// File "héllo world, this" (18 bytes): h(0) é(1-2) l(3) l(4) o(5)
// sp(6) w(7) o(8) r(9) l(10) d(11) ,(12) sp(13) t(14) h(15) i(16) s(17).
// Window starts at byte 6 (the space), so window = " world, this"; the
// context is the preceding runes "héllo" (5 runes, 6 bytes), starting at
// byte 0. The snippet spans absolute runes [0, ...), so IMEOffsetRune=0.
st := newStringState("héllo world, this")
st.Editor.IMEWindowStartByte = 6
st.Editor.IMEWindowText = " world, this"
st.Editor.IMEContext = "héllo"
st.Editor.IMEContextStartByte = 0
st.Editor.IMEOffsetRune = 0
// Replace absolute rune [1,2) ("é", bytes 1..3) with "@": inside context.
HandleReplaceRange(1, 2, "@")
if want := "h@llo world, this"; st.Editor.Buffer != want {
t.Fatalf("context replace: got %q, want %q", st.Editor.Buffer, want)
}
if cp := TheState.Editor.CursorPosition; cp != 2 {
t.Fatalf("cursor = %d, want 2", cp)
}
}
// TestHandleReplaceRange_SpansContextWindow verifies a replace range that
// straddles the context/window boundary maps correctly through both.
func TestHandleReplaceRange_SpansContextWindow(t *testing.T) {
st := newStringState("héllo world, this")
st.Editor.IMEWindowStartByte = 6
st.Editor.IMEWindowText = " world, this"
st.Editor.IMEContext = "héllo"
st.Editor.IMEContextStartByte = 0
st.Editor.IMEOffsetRune = 0
// Absolute runes [4,7): rune4='o'(context, byte5) + rune5=sp(window,
// byte6) + rune6='w'(window, byte7) -> bytes [5,8) = "o w". Keeping
// bytes 0..4 ("héll") and 8+ ("orld, this").
HandleReplaceRange(4, 7, "ZZ")
if want := "héllZZorld, this"; st.Editor.Buffer != want {
t.Fatalf("spanning replace: got %q, want %q", st.Editor.Buffer, want)
}
if cp := TheState.Editor.CursorPosition; cp != 7 {
t.Fatalf("cursor = %d, want 7", cp)
}
}
// TestRuneAnchorTracking verifies the incremental rune-count cache: it must
// follow edits (imeAnchorEdit) and agree with a naive full count after a
// sequence of mutations at various positions.
func TestRuneAnchorTracking(t *testing.T) {
st := newChunkedState(t, "hello wörld, this is a test", 8)
check := func(what string) {
t.Helper()
full, _ := st.Editor.ChunkedBuffer.FullContent()
for _, pos := range []int{0, 3, 9, len(full)} {
if got, want := st.imeRuneOffsetAt(pos), countRunes(full[:pos]); got != want {
t.Fatalf("%s: imeRuneOffsetAt(%d) = %d, want %d", what, pos, got, want)
}
}
}
check("initial")
testInsertAt(t, st, 3, "é")
check("insert mid")
testInsertAt(t, st, int(st.Editor.ChunkedBuffer.FileLen()), "é")
check("insert end")
testDeleteAt(t, st, 2, 4)
check("delete")
}
// testInsertAt inserts text at absolute byte pos on the test state, adjusting
// the IME rune anchor the same way the production edit paths do.
func testInsertAt(t *testing.T, st *State, pos int, text string) {
t.Helper()
st.Editor.ChunkedBuffer.Insert(pos, text)
st.Editor.ChunkedBuffer.UpdateLineIndexAfterInsert(pos, text)
st.Editor.CursorPosition = pos + len(text)
imeAnchorEdit(pos, "", text)
}
// testDeleteAt deletes n bytes at absolute byte pos on the test state.
func testDeleteAt(t *testing.T, st *State, pos, n int) {
t.Helper()
del := st.Editor.ChunkedBuffer.Content(pos, pos+n)
st.Editor.ChunkedBuffer.Delete(pos, n)
st.Editor.ChunkedBuffer.UpdateLineIndexAfterDelete(pos, pos+n)
imeAnchorEdit(pos, del, "")
}

View File

@ -73,6 +73,7 @@ type Logic struct {
openFileChan chan string
retryChan chan string // auto-save retries
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
// flushSession: one-shot request to persist the session snapshot now
// (the OS activity onStop hook, see FlushSession). Buffered 1 so the
// requester never blocks, even if an earlier flush is still queued.
@ -434,6 +435,14 @@ func (l *Logic) openFile(path string) {
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
cb.SetWorkerPool(l.workerPool)
TheState.Editor.ChunkedBuffer = cb
// New file: the previous file's IME rune-count cache and window bookkeeping
// are meaningless. Reset the anchor so the next imeRuneOffsetAt recomputes
// from scratch (the window/context fields are recomputed by buildFrame).
TheState.Editor.imeRuneCacheByte = 0
TheState.Editor.imeRuneCacheCount = 0
TheState.Editor.IMEContext = ""
TheState.Editor.IMEContextStartByte = 0
TheState.Editor.IMEOffsetRune = 0
// Dispatch stat task to get file size
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
@ -753,7 +762,7 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
} else if res.TaskType == pool.TypeReadFile {
if res.Success {
if content, ok := res.Data.([]byte); ok {
if l.state.Editor.ChunkedBuffer != nil {
if l.state.Editor.ChunkedBuffer != nil {
// Full-load the in-range file: set the whole content (split into
// resident chunks). No lazy load, so no stale-disk re-read.
l.state.Editor.ChunkedBuffer.SetFileSize(int64(len(content)))
@ -804,10 +813,19 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
if res.Success {
if chunk, ok := res.Data.([]byte); ok {
for len(cb.chunks) <= res.ChunkIdx {
cb.chunks = append(cb.chunks, nil)
// A full-file load (SetContent) may have finished while this
// lazy chunk read was in flight; its resident chunks are the
// authoritative content and must not be clobbered by a stale
// lazy result (which can also be a short read). Applying it
// would truncate/shift the chunk and move every byte offset
// after it — taps and IME commits landing at the wrong
// position (text corruption).
if !cb.fullyLoaded {
for len(cb.chunks) <= res.ChunkIdx {
cb.chunks = append(cb.chunks, nil)
}
cb.chunks[res.ChunkIdx] = chunk
}
cb.chunks[res.ChunkIdx] = chunk
}
} else {
log.Printf("Logic: ReadChunkTask failed for %s chunk %d: %v", res.FilePath, res.ChunkIdx, res.Error)

View File

@ -83,12 +83,31 @@ type EditorState struct {
// IMEWindowStartByte is the absolute byte offset in the buffer where the
// visible window (IMEWindowText) begins. For small (string) files it is 0
// and the window is the whole buffer; for large (chunked) files it is the
// viewport start. The IME snippet is the window, so an EditEvent.Range is
// relative to the window and must be offset by this to address the buffer.
// viewport start. HandleReplaceRange uses it to convert IME offsets to
// absolute file offsets.
IMEWindowStartByte int
// IMEWindowText is the visible window text shown to the IME (the snippet).
// It is set during layout and is what an EditEvent.Range indexes into.
// It is set during layout.
IMEWindowText string
// IMEContext is up to 10 runes immediately before IMEWindowStartByte. It
// is prepended to the window when the snippet is pushed to the IME, so the
// IME always sees real text preceding the caret — auto-cap must never
// treat the top of the visible window as the start of a sentence.
IMEContext string
// IMEContextStartByte is the absolute byte offset where IMEContext begins
// (IMEWindowStartByte - len(IMEContext)).
IMEContextStartByte int
// IMEOffsetRune is the rune count of file[0:IMEContextStartByte): the
// absolute file rune offset where the pushed snippet starts (its
// Range.Start). A non-zero value tells the IME the window sits
// mid-document, keeping its sentence/word-boundary logic honest. IME edit
// ranges are addressed from this offset (see imeRuneToByte).
IMEOffsetRune int
// imeRuneCache{Byte,Count} memoizes "rune count of [0, Byte)" so
// IMEOffsetRune stays O(delta) while the window scrolls; edits adjust it
// (imeAnchorEdit) and a file switch resets it to (0,0).
imeRuneCacheByte int
imeRuneCacheCount int
// EditSeq counts content edits (incremented by markDirty). The shaped
// glyph layout arriving via layoutChan is only applied to the WrapIndex
// when its EditSeq matches, so a layout shaped before an edit can never
@ -949,6 +968,7 @@ func deleteRange(start, end int) {
if end <= start {
return
}
del := TheState.fileContent(start, end)
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
buf.Delete(start, end-start)
buf.UpdateLineIndexAfterDelete(start, end)
@ -956,6 +976,7 @@ func deleteRange(start, end int) {
str := TheState.Editor.Buffer
TheState.Editor.Buffer = str[:start] + str[end:]
}
imeAnchorEdit(start, del, "")
TheState.Editor.findEdit(start, end, 0)
}
@ -1949,6 +1970,7 @@ func HandleDelete() {
if w := utf8AdvanceWidth(seg); w > 0 {
buf.Delete(pos, w)
buf.UpdateLineIndexAfterDelete(pos, pos+w)
imeAnchorEdit(pos, seg[:w], "")
e.findEdit(pos, pos+w, 0)
markDirty()
}
@ -1965,6 +1987,7 @@ func HandleDelete() {
}
w := utf8AdvanceWidth(str[pos:end])
TheState.Editor.Buffer = str[:pos] + str[pos+w:]
imeAnchorEdit(pos, str[pos:pos+w], "")
e.findEdit(pos, pos+w, 0)
markDirty()
}
@ -1990,6 +2013,7 @@ func HandleInsert(s string) {
str := e.Buffer
e.Buffer = str[:pos] + s + str[pos:]
}
imeAnchorEdit(pos, "", s)
e.findEdit(pos, pos, len(s))
e.CursorPosition = pos + len(s)
markDirty()
@ -2019,6 +2043,7 @@ func HandleBackspace() {
w := utf8BackspaceWidth(buf.Content(segStart, pos))
buf.Delete(pos-w, w)
buf.UpdateLineIndexAfterDelete(pos-w, pos)
imeAnchorEdit(pos-w, buf.Content(pos-w, pos), "")
TheState.Editor.CursorPosition = pos - w
e.findEdit(pos-w, pos, 0)
markDirty()
@ -2032,6 +2057,7 @@ func HandleBackspace() {
}
w := utf8BackspaceWidth(str[segStart:pos])
TheState.Editor.Buffer = str[:pos-w] + str[pos:]
imeAnchorEdit(pos-w, str[pos-w:pos], "")
TheState.Editor.CursorPosition = pos - w
e.findEdit(pos-w, pos, 0)
markDirty()
@ -2058,17 +2084,165 @@ func runeIndexToByteStr(s string, n int) int {
return len(s)
}
// HandleReplaceRange replaces the text in the rune range [startRune, endRune)
// with text and places the cursor at the end of the inserted text.
// countRunes counts the runes in s.
func countRunes(s string) int {
n := 0
for i := 0; i < len(s); {
_, w := utf8.DecodeRuneInString(s[i:])
i += w
n++
}
return n
}
// lastRunesStart returns the byte offset where the last n runes of s begin
// (0 if s has fewer than n runes). It stops at rune boundaries, so the result
// never splits a UTF-8 sequence.
func lastRunesStart(s string, n int) int {
start := len(s)
for i := 0; i < n && start > 0; i++ {
start--
// Walk back over continuation bytes (0x80-0xBF) to the lead byte.
for start > 0 && s[start]&0xC0 == 0x80 {
start--
}
}
return start
}
// fileContent returns file[lo:hi) from the active buffer (chunked or small).
func (s *State) fileContent(lo, hi int) string {
e := &s.Editor
if e.ChunkedBuffer != nil {
return e.ChunkedBuffer.Content(lo, hi)
}
if lo < 0 {
lo = 0
}
if hi > len(e.Buffer) {
hi = len(e.Buffer)
}
if lo > hi {
lo = hi
}
return e.Buffer[lo:hi]
}
// bufferLen is the active buffer length in bytes.
func (s *State) bufferLen() int {
if e := &s.Editor; e.ChunkedBuffer != nil {
return int(e.ChunkedBuffer.FileLen())
}
return len(s.Editor.Buffer)
}
// imeLeadingContext returns up to 10 runes immediately before byte position
// start in the active buffer ("" at file start, or for too-large files). At
// most 64 bytes are scanned (10 runes * 4 bytes + margin).
func imeLeadingContext(start int) string {
if start <= 0 || TheState.Editor.TooLarge {
return ""
}
a := start - 64
if a < 0 {
a = 0
}
span := TheState.fileContent(a, start)
return span[lastRunesStart(span, 10):]
}
// imeRuneOffsetAt returns the rune count of the file prefix [0, bytePos).
// It scans only the delta from the cached anchor (imeRuneCacheByte/Count), so
// repeated calls with a slowly moving byte position (scrolling while typing)
// are O(delta); a large jump (file switch, long scroll) does one full scan
// and then anchors again. Must be called on the logic goroutine (owner).
func (s *State) imeRuneOffsetAt(bytePos int) int {
e := &s.Editor
fileLen := s.bufferLen()
if bytePos > fileLen {
bytePos = fileLen
}
if e.imeRuneCacheByte > fileLen {
e.imeRuneCacheByte, e.imeRuneCacheCount = 0, 0
}
if bytePos == e.imeRuneCacheByte {
return e.imeRuneCacheCount
}
delta := bytePos - e.imeRuneCacheByte
const maxDelta = 256 * 1024
if delta > maxDelta || delta < -maxDelta {
n := countRunes(s.fileContent(0, bytePos))
e.imeRuneCacheByte, e.imeRuneCacheCount = bytePos, n
return n
}
lo, hi := e.imeRuneCacheByte, bytePos
if hi < lo {
lo, hi = hi, lo
}
n := e.imeRuneCacheCount
if delta > 0 {
n += countRunes(s.fileContent(lo, hi))
} else {
n -= countRunes(s.fileContent(lo, hi))
}
e.imeRuneCacheByte, e.imeRuneCacheCount = bytePos, n
return n
}
// imeAnchorEdit adjusts the IME rune-count cache after a buffer mutation at
// absolute byte position editByte that removed delText and inserted insText.
// An edit at or after the cached position does not change the rune count of
// [0, cacheByte), so only edits strictly before it move the anchor.
func imeAnchorEdit(editByte int, delText, insText string) {
e := &TheState.Editor
if editByte < e.imeRuneCacheByte {
e.imeRuneCacheByte += len(insText) - len(delText)
e.imeRuneCacheCount += countRunes(insText) - countRunes(delText)
}
}
// runeToByteWhole maps an absolute file rune offset (the IME's coordinate
// space, in which the pushed snippet starts at IMEOffsetRune) to an absolute
// buffer byte offset by scanning the WHOLE active buffer, in 8 KiB steps.
// Unlike a window-relative mapping it does not depend on the visible window,
// so it stays exact while the window moves (a fling in flight): scrolling
// moves the window, not the buffer. One pass over the file is ~1-2 ms for a
// 1 MB file — negligible next to the disk save a commit triggers.
func runeToByteWhole(runePos int) int {
if runePos <= 0 {
return 0
}
total, off, fileLen := 0, 0, TheState.bufferLen()
const step = 8 * 1024
for off < fileLen {
hi := off + step
if hi > fileLen {
hi = fileLen
}
n := countRunes(TheState.fileContent(off, hi))
if total+n >= runePos {
return off + runeIndexToByteStr(TheState.fileContent(off, hi), runePos-total)
}
total += n
off = hi
}
return fileLen
}
// HandleReplaceRange replaces the text in the rune range [startRune,
// endRune) with text and places the cursor at the end of the inserted text.
//
// This implements the IME replacement contract (key.EditEvent.Range): a swipe
// or autocorrect commit replaces the selected region instead of blindly
// inserting at the cursor, so the old text is removed (no duplication). It
// also covers plain inserts (start==end) and range deletes (text == "").
//
// startRune/endRune are RUNE indices (the IME's text model), while the buffer
// is byte-based, so they are converted to byte offsets first. Must be called
// on the logic goroutine (owner).
// startRune/endRune are ABSOLUTE file rune offsets (the IME's coordinate
// space, in which the pushed snippet starts at IMEOffsetRune), while the
// buffer is byte-based, so they are converted to absolute byte offsets first
// (runeToByteWhole, against the whole buffer). Used by the test harness
// (editor.HandleKeyDown with a key.EditEvent); the real app routes commits
// through HandleIMECommit. Must be called on the logic goroutine (owner).
func HandleReplaceRange(startRune, endRune int, text string) {
// A too-large file is not editable: ignore IME commits.
if TheState.Editor.TooLarge {
@ -2077,88 +2251,108 @@ func HandleReplaceRange(startRune, endRune int, text string) {
if startRune > endRune {
startRune, endRune = endRune, startRune
}
// The IME snippet is the visible window (IMEWindowText), so startRune/
// endRune are relative to that window. Resolve them to window-byte
// offsets, then add the window's absolute start to address the buffer.
// For small (string) files the window is the whole buffer (start 0), so
// this reduces to absolute addressing. If IMEWindowText is empty (tests
// that never run layout), fall back to the whole buffer as the window.
windowStart := TheState.Editor.IMEWindowStartByte
windowText := TheState.Editor.IMEWindowText
if windowText == "" {
windowStart = 0
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
windowText, _ = cb.FullContent()
} else {
windowText = TheState.Editor.Buffer
applyIMECommitBytes(runeToByteWhole(startRune), runeToByteWhole(endRune), text)
}
// IMECommit is an IME commit whose range is in ABSOLUTE FILE RUNES — the
// coordinate space of the pushed snippet, whose Range.Start is IMEOffsetRune
// (see editorLayout). The logic maps it to bytes against the whole buffer
// (runeToByteWhole), so it stays exact while the visible window moves
// (a fling in flight): scrolling moves the window, not the buffer.
type IMECommit struct {
StartRune int
EndRune int
Text string
}
// HandleIMECommit applies an IME commit from the real app (cmd/pad/main.go
// routes editor_text key.EditEvents here; the test harness uses
// HandleReplaceRange). In addition to the whole-buffer mapping it applies
// the drift guard: Android's IME contract (InputConnection) says commit
// positions are relative to the text the IME last received; if a
// restartInput is dropped (observed with Gboard during flings), the IME's
// text is stale and its reported position is in the stale text's
// coordinates. A small commit (range ≤ 2 runes) is always anchored at the
// caret the IME was last told about — an insertion is [c,c), a composing
// replacement [c-1,c). If the reported range ends elsewhere, the IME's text
// is stale: snap the commit to the cursor, the only position it cannot
// drift from. Must be called on the logic goroutine (owner).
func HandleIMECommit(data any) {
c, ok := data.(IMECommit)
if !ok {
return
}
// A too-large file is not editable: ignore IME commits.
if TheState.Editor.TooLarge {
return
}
startRune, endRune := c.StartRune, c.EndRune
if startRune > endRune {
startRune, endRune = endRune, startRune
}
if endRune-startRune <= 2 {
caretRune := TheState.imeRuneOffsetAt(TheState.Editor.CursorPosition)
if endRune != caretRune {
log.Printf("IME DRIFT-SNAP range=[%d,%d) caret=%d -> snap to caret", startRune, endRune, caretRune)
startRune, endRune = caretRune, caretRune
}
}
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
absStart, absEnd := runeToByteWhole(startRune), runeToByteWhole(endRune)
log.Printf("IME COMMIT range=[%d,%d) text=%q -> [%d,%d)", startRune, endRune, c.Text, absStart, absEnd)
applyIMECommitBytes(absStart, absEnd, c.Text)
}
// applyIMECommitBytes replaces [startByte, endByte) with text and places the
// cursor at the end of the inserted text. Both byte addresses are absolute
// file offsets. Shared by HandleReplaceRange (rune-translated) and
// HandleIMECommit (model-translated). Must run on the logic goroutine.
func applyIMECommitBytes(startByte, endByte int, text string) {
if startByte > endByte {
startByte, endByte = endByte, startByte
}
// A live selection is always replaced by the commit: union the IME range
// with the selection so the outcome is deterministic no matter what the
// IME reports (some IMEs send the full selection range, others send an
// empty range at the caret expecting the app to consume its reported
// selection).
if selActive() {
if absStart > TheState.Editor.SelectionStart {
absStart = TheState.Editor.SelectionStart
if startByte > TheState.Editor.SelectionStart {
startByte = TheState.Editor.SelectionStart
}
if absEnd < TheState.Editor.SelectionEnd {
absEnd = TheState.Editor.SelectionEnd
if endByte < TheState.Editor.SelectionEnd {
endByte = TheState.Editor.SelectionEnd
}
}
if imeDebugLog {
fmt.Printf("IME DEBUG HandleReplaceRange: startRune=%d endRune=%d text=%q windowStart=%d windowLen=%d -> absStart=%d absEnd=%d\n",
startRune, endRune, text, windowStart, len(windowText), absStart, absEnd)
}
// Capture the deleted text (before the mutation) for the rune-anchor.
delText := TheState.fileContent(startByte, endByte)
var newCursor int
if buf := TheState.Editor.ChunkedBuffer; buf != nil {
if absEnd > absStart {
buf.Delete(absStart, absEnd-absStart)
if endByte > startByte {
buf.Delete(startByte, endByte-startByte)
}
buf.Insert(absStart, text)
if absEnd > absStart {
buf.UpdateLineIndexAfterDelete(absStart, absEnd)
buf.Insert(startByte, text)
if endByte > startByte {
buf.UpdateLineIndexAfterDelete(startByte, endByte)
}
buf.UpdateLineIndexAfterInsert(absStart, text)
newCursor = absStart + len(text)
buf.UpdateLineIndexAfterInsert(startByte, text)
newCursor = startByte + len(text)
} else {
s := TheState.Editor.Buffer
if absEnd > absStart {
s = s[:absStart] + s[absEnd:]
if endByte > startByte {
s = s[:startByte] + s[endByte:]
}
TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
newCursor = absStart + len(text)
TheState.Editor.Buffer = s[:startByte] + text + s[startByte:]
newCursor = startByte + len(text)
}
TheState.Editor.findEdit(absStart, absEnd, len(text))
imeAnchorEdit(startByte, delText, text)
TheState.Editor.findEdit(startByte, endByte, len(text))
TheState.Editor.CursorPosition = newCursor
// A commit consumed any selection it overlapped (see the union above).
ClearSelection()
if imeDebugLog {
dbgBuf := currentEditorText()
if len(dbgBuf) > 40 {
dbgBuf = dbgBuf[:40]
}
fmt.Printf("IME DEBUG -> newCursor=%d buffer=%q\n", newCursor, dbgBuf)
}
markDirty()
}
// imeDebugLog enables verbose per-commit IME logging. Keep false in normal
// use; enable when debugging IME commit/cursor sync on device.
const imeDebugLog = false
// currentEditorText returns the current editor buffer contents (full for
// chunked files). Used only for debug logging.
func currentEditorText() string {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
s, _ := cb.FullContent()
return s
}
return TheState.Editor.Buffer
}
func markDirty() {
// Every content edit funnels through here, so EditSeq is the universal
@ -2430,7 +2624,6 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
// range [start, end) so a tall viewport is filled (a fixed 2000-byte
// window could leave the lower rows blank).
visibleContent = cb.Content(start, end)
// Adjust cursor position to be relative to visibleContent. It may be
// NEGATIVE (the cursor's line scrolled above the window) or exceed
// len(visibleContent) (scrolled below): the renderer treats an
@ -2481,12 +2674,16 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
TheState.VisibleStart = start
TheState.VisibleEnd = end
// Record the visible window for IME: the snippet is this window, so an
// EditEvent.Range (relative to the window) is offset by IMEWindowStartByte
// to address the buffer. start is 0 for small (string) files, so the
// window is the whole buffer there.
// Record the visible window for IME: the snippet pushed to the IME is
// the leading context plus this window, addressed from IMEOffsetRune (the
// absolute file rune offset where the context begins). start is 0 for
// small (string) files, so the window is the whole buffer there and there
// is no leading context. See imeRuneToByte / Renderer.FlushIME.
TheState.Editor.IMEWindowStartByte = start
TheState.Editor.IMEWindowText = visibleContent
TheState.Editor.IMEContext = imeLeadingContext(start)
TheState.Editor.IMEContextStartByte = start - len(TheState.Editor.IMEContext)
TheState.Editor.IMEOffsetRune = TheState.imeRuneOffsetAt(TheState.Editor.IMEContextStartByte)
// Window-relative selection for the TextField (byte offsets into
// visibleContent); -1 means nothing visible is selected. The IME push and
// the in-app highlight consume this; the logic side keeps absolute
@ -2571,6 +2768,13 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
{Gesture: ui.Pinch, Handler: HandleFontPinch},
},
)
// Lead the IME snippet with up to 10 runes before the window and address
// it from its true file position (see imeLeadingContext / IMEOffsetRune
// above and Renderer.FlushIME): this is what stops the keyboard from
// auto-capitalizing mid-sentence at the top of the visible window.
editorElem.IMEContext = TheState.Editor.IMEContext
editorElem.IMEOffset = TheState.Editor.IMEOffsetRune
editorElem.WindowStartByte = start
// While the find bar is open, key focus belongs to the main-owned
// "find_bar" widget.Editor: the editor stops its per-frame IME sync, and
// regaining "editor_text" focus re-issues key.FocusCmd on close (see
@ -2776,6 +2980,8 @@ func SetCursorFromPoint(x, y float64) {
ClearSelection()
if pos, ok := textPosFromLocalPoint(x, y); ok {
TheState.Editor.CursorPosition = pos
log.Printf("IME TAP local=(%.0f,%.0f) cursor=%d winStart=%d offsetRune=%d",
x, y, pos, TheState.Editor.IMEWindowStartByte, TheState.Editor.IMEOffsetRune)
}
}

View File

@ -1,6 +1,7 @@
package real
import (
"errors"
"io"
"os"
"path/filepath"
@ -64,11 +65,23 @@ func (fs *RealFileSystem) ReadFileAt(path string, offset, size int) ([]byte, err
}
defer f.Close()
buf := make([]byte, size)
n, err := f.ReadAt(buf, int64(offset))
if err != nil && err != io.EOF {
return nil, err
// ReadAt may return a SHORT read with a nil or io.EOF error (POSIX
// allows it; the Android FUSE layer does it), so loop until the whole
// range is read or the file truly ends. A silently short result would
// truncate a chunk and shift every byte offset after it — taps and IME
// commits would then land at the wrong buffer position (text corruption).
total := 0
for total < size {
n, err := f.ReadAt(buf[total:], int64(offset)+int64(total))
total += n
if err != nil {
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
break
}
return nil, err
}
}
return buf[:n], nil
return buf[:total], nil
}
func (fs *RealFileSystem) WriteFile(path string, content []byte) error {

View File

@ -170,11 +170,15 @@ func TestRealFile_Fuzz_EditSaveReload(t *testing.T) {
t.Fatalf("batch %d op %d: no frame: %v", batch, j, ferr)
}
type win struct {
Start int
Text string
Start int
Text string
CtxStart int
Context string
OffsetRune int
}
raw, err := h.Inspect(func(st *editor.State) any {
return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText}
return win{st.Editor.IMEWindowStartByte, st.Editor.IMEWindowText,
st.Editor.IMEContextStartByte, st.Editor.IMEContext, st.Editor.IMEOffsetRune}
})
if err != nil {
t.Fatalf("batch %d op %d: inspect: %v", batch, j, err)
@ -191,17 +195,39 @@ func TestRealFile_Fuzz_EditSaveReload(t *testing.T) {
batch, j, w.Start, len(w.Text),
compactMismatch(w.Text, model[w.Start:min(len(model), w.Start+len(w.Text))]))
}
wr := countRunes(w.Text)
ra := rng.Intn(wr + 1)
rb := ra + rng.Intn(wr-ra+1)
// Context consistency: it is the slice immediately before the
// window, and the snippet's Range.Start is the rune offset of
// the context start.
if len(w.Context) > 0 {
if w.CtxStart+len(w.Context) != w.Start ||
w.Context != model[w.CtxStart:w.CtxStart+len(w.Context)] {
t.Fatalf("batch %d op %d: IME context desync: ctxStart=%d", batch, j, w.CtxStart)
}
}
if w.OffsetRune != countRunes(model[:w.CtxStart]) {
t.Fatalf("batch %d op %d: IME offset desync: got=%d want=%d", batch, j, w.OffsetRune, countRunes(model[:w.CtxStart]))
}
// The real IME reports ABSOLUTE file runes: the pushed snippet
// (context+window) starts at OffsetRune, so a snippet-local
// range [sa,sb) is absolute [OffsetRune+sa, OffsetRune+sb).
ctxRunes := countRunes(w.Context)
total := ctxRunes + countRunes(w.Text)
sa := rng.Intn(total + 1)
sb := sa + rng.Intn(total-sa+1)
absByte := func(snipRune int) int {
if snipRune <= ctxRunes {
return w.CtxStart + runeToByte(w.Context, snipRune)
}
return w.Start + runeToByte(w.Text, snipRune-ctxRunes)
}
text := randomFuzzText(rng, 0, 12)
if err := h.WithState(func(st *editor.State) {
editor.HandleReplaceRange(ra, rb, text)
editor.HandleReplaceRange(w.OffsetRune+sa, w.OffsetRune+sb, text)
}); err != nil {
t.Fatalf("batch %d op %d: replace: %v", batch, j, err)
}
bs := w.Start + runeToByte(w.Text, ra)
be := w.Start + runeToByte(w.Text, rb)
bs := absByte(sa)
be := absByte(sb)
model = model[:bs] + text + model[be:]
}
}

View File

@ -0,0 +1,219 @@
package e2e_test
import (
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gioui.org/io/key"
"pad/internal/editor"
"pad/internal/ui"
)
// largeFileContent generates a deterministic ~1.6 MB text file: 40,000 lines
// of ~40 unique ASCII bytes each, with a multibyte rune ("é", "中") sprinkled
// through every 500th line so rune offsets and byte offsets genuinely differ.
// Generating in-test (t.TempDir, via realFileHarness) keeps the repository
// small while still exercising the >500 KB chunked/IME path the same way a
// real multi-hundred-kb note would.
func largeFileContent(lines int) string {
var b strings.Builder
for i := 0; i < lines; i++ {
b.WriteString(fmt.Sprintf("large file line %05d pad", i))
if i%500 == 0 {
b.WriteString(" é 中")
}
b.WriteString(strings.Repeat(string(rune('a'+i%26)), 12))
b.WriteString("\n")
}
return b.String()
}
// TestRealFile_LargeFile_IMECommits pins IME commit correctness on a large
// (~1.6 MB, well over the 500 KB threshold that the on-device "needs a
// gioui fork" claim referred to) file:
//
// - the IME window invariants (window text, leading context, absolute
// rune offset) hold for a window deep in the file;
// - a replacement commit (a selection, as a swipe/autocorrect would send)
// lands exactly in the scrolled window and nowhere else;
// - an insert commit (empty range at the caret, a plain typed char) in a
// second, deeper window lands exactly at the caret.
//
// The full-content comparison after each commit catches the classic
// corruption mode (the commit mapped through a stale/zero window start and
// clobbering text near the top of the file).
func TestRealFile_LargeFile_IMECommits(t *testing.T) {
const (
lines = 40000
scrollLn = 20000 // scroll deep into the file
editLn = 20008 // comfortably inside the ~18-line viewport window
)
model := largeFileContent(lines)
h, path := realFileHarness(t, "large.txt", model)
defer h.Cleanup()
h.SendConfig(780, 400)
if _, err := h.WaitForFrame(5 * time.Second); err != nil {
t.Fatalf("wait for frame: %v", err)
}
// Scroll deep (5 dp into the line: avoid the float32/float64 line-pitch
// boundary fragility, see TestRealFile_WindowSelectionAtScroll) and put a
// selection on line editLn, 6 bytes into its filler.
selStart := byteOffsetOfLine(t, model, editLn) + 31
selEnd := selStart + 6
if err := h.WithState(func(st *editor.State) {
st.ScrollOffset = ui.Dp(16.8*scrollLn + 5)
st.Editor.SelectionStart = selStart
st.Editor.SelectionEnd = selEnd
st.Editor.CursorPosition = selEnd
}); err != nil {
t.Fatalf("WithState: %v", err)
}
prev := h.FrameCount()
h.SendConfig(780, 400)
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for scrolled frame: %v", err)
}
// 1) IME window invariants for a window deep in a large file.
var win struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
}
v, err := h.Inspect(func(st *editor.State) any {
e := &st.Editor
return struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
}{e.IMEWindowStartByte, e.IMEWindowText, e.IMEContextStartByte, e.IMEContext, e.IMEOffsetRune}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
win = v.(struct {
Start int
Text string
CtxStart int
Context string
OffsetRune int
})
if want := model[win.Start : win.Start+len(win.Text)]; win.Text != want {
t.Fatalf("IME window text desync at start=%d", win.Start)
}
if win.CtxStart+len(win.Context) != win.Start ||
win.Context != model[win.CtxStart:win.Start] {
t.Fatalf("IME context desync: ctxStart=%d ctx=%q", win.CtxStart, win.Context)
}
if want := utf8.RuneCountInString(model[:win.CtxStart]); win.OffsetRune != want {
t.Fatalf("IME offset rune = %d, want %d", win.OffsetRune, want)
}
// 2) Replacement commit: absolute file runes (the coordinate space the
// real IME uses; the multibyte sprinkles make runes != bytes).
absStart := utf8.RuneCountInString(model[:selStart])
absEnd := utf8.RuneCountInString(model[:selEnd])
wantModel := model[:selStart] + "ZZ" + model[selEnd:]
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: absStart, End: absEnd},
Text: "ZZ",
},
}})
time.Sleep(100 * time.Millisecond)
got, err := h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if got != wantModel {
t.Fatalf("after replacement commit: content differs (first diff at %d)", firstDiff(wantModel, got))
}
// 3) Insert commit in a deeper window: caret-only (empty range), the
// plain "typed a character" case.
deepLn := 30000
caret := byteOffsetOfLine(t, wantModel, deepLn) + 20
if err := h.WithState(func(st *editor.State) {
st.ScrollOffset = ui.Dp(16.8*float64(deepLn) + 5)
st.Editor.SelectionStart = -1
st.Editor.SelectionEnd = -1
st.Editor.CursorPosition = caret
}); err != nil {
t.Fatalf("WithState: %v", err)
}
prev = h.FrameCount()
h.SendConfig(780, 400)
if _, err := h.WaitForFrameCount(prev+1, 5*time.Second); err != nil {
t.Fatalf("wait for second scrolled frame: %v", err)
}
absCaret := utf8.RuneCountInString(wantModel[:caret])
wantModel2 := wantModel[:caret] + "Q" + wantModel[caret:]
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: absCaret, End: absCaret},
Text: "Q",
},
}})
time.Sleep(100 * time.Millisecond)
got, err = h.FullContent()
if err != nil {
t.Fatalf("FullContent: %v", err)
}
if got != wantModel2 {
t.Fatalf("after insert commit: content differs (first diff at %d)", firstDiff(wantModel2, got))
}
if cp, err := h.Inspect(func(st *editor.State) any { return st.Editor.CursorPosition }); err != nil {
t.Fatalf("Inspect: %v", err)
} else if cp.(int) != caret+1 {
t.Fatalf("cursor = %d, want %d", cp.(int), caret+1)
}
// 4) Persistence: the on-disk file matches the in-memory content.
if err := h.Flush(); err != nil {
t.Fatalf("flush: %v", err)
}
if disk := readDisk(t, path); disk != wantModel2 {
t.Fatalf("disk content differs from in-memory (first diff at %d)", firstDiff(wantModel2, disk))
}
}
// byteOffsetOfLine returns the absolute byte offset of the start of line n
// (0-indexed) in model. The generated lines are unique, so a plain index of
// the line prefix is unambiguous.
func byteOffsetOfLine(t *testing.T, model string, n int) int {
t.Helper()
prefix := fmt.Sprintf("large file line %05d", n)
i := strings.Index(model, prefix)
if i < 0 {
t.Fatalf("line %d not found", n)
}
return i
}
// firstDiff returns the byte offset of the first difference between a and b
// (min(len) when they are a common prefix), for useful failure messages.
func firstDiff(a, b string) int {
n := len(a)
if len(b) < n {
n = len(b)
}
for i := 0; i < n; i++ {
if a[i] != b[i] {
return i
}
}
return n
}

View File

@ -137,9 +137,11 @@ func TestRealFile_IMECommitAtScroll(t *testing.T) {
t.Fatalf("wait for frame: %v", err)
}
// The selection (absolute) and the matching window-relative EditEvent
// range: the IME reports offsets relative to the pushed snippet, which
// is the visible window.
// The selection (absolute) and the matching EditEvent range: the IME
// reports offsets in absolute file runes (the coordinate space of the
// pushed snippet, which now starts at the leading context's true file
// position). The content is ASCII, so rune == byte and the range is just
// the absolute byte offsets.
selStart := editLn*lineLen + 5
selEnd := editLn*lineLen + 11
// Scroll 5 dp into line 10 (see TestRealFile_WindowSelectionAtScroll for
@ -159,12 +161,14 @@ func TestRealFile_IMECommitAtScroll(t *testing.T) {
}
// Typing replaces the selection: "ZZ" over the 6 selected filler chars
// (in-line offsets [5,11)) on line 12. The pre-fix code (windowStart=0)
// applied this at byte 47 — line 2 — corrupting the file.
// (in-line offsets [5,11)) on line 12. The commit range is in absolute
// file runes (== bytes here). The pre-fix code (windowStart=0, no
// leading context) applied a mis-mapped range near the top of the file,
// corrupting it.
h.SendInput([]ui.InputEvent{{
Handler: editor.HandleKeyDown,
Data: key.EditEvent{
Range: key.Range{Start: selStart - scrollLn*lineLen, End: selEnd - scrollLn*lineLen},
Range: key.Range{Start: selStart, End: selEnd},
Text: "ZZ",
},
}})

View File

@ -503,7 +503,18 @@ func TestRestore_ScrollSurvivesWrapState(t *testing.T) {
h1.Run()
h1.SendConfig(780, 1688)
h1.SendScale(2.0)
defer h1.Cleanup()
// The harness runs against the package globals (TheState/TheLogic), so
// only ONE harness may be alive at a time: h1 is stopped (not deferred)
// as soon as its snapshot is in hand, before h2's NewLogic overwrites
// the globals out from under h1's still-running goroutines.
h1Cleaned := false
cleanup1 := func() {
if !h1Cleaned {
h1Cleaned = true
h1.Cleanup()
}
}
defer cleanup1()
if err := h1.WithState(func(st *editor.State) { editor.OpenFile("/wrap.txt") }); err != nil {
t.Fatalf("OpenFile: %v", err)
@ -563,6 +574,9 @@ got:
t.Errorf("snapshot ScrollSub = %v, want ~0", snap.ScrollSub)
}
// h1's job is done: stop it before h2 overwrites the package globals.
cleanup1()
// Second session (the relaunch): the WrapIndex is fresh, every count the
// estimate (1). The restore must land on logical line 200, not on visual
// line 500 mapped 1:1 (which would be line 500, far deeper).

View File

@ -213,6 +213,20 @@ type TextField struct {
// is running (the IME insets dispatches redraw the app), so the keyboard
// could not be dismissed. See TextField.Draw.
ShowIMESeq uint64
// IMEContext is the leading context (up to 10 runes immediately before
// the window start) that the renderer prepends to Value when pushing the
// IME snippet; IMEOffset is the absolute file rune offset where
// IMEContext begins (the pushed snippet's Range.Start). Computed by the
// logic layer at frame-build time; see Renderer.FlushIME and the IME
// fields on EditorState (IMEContext/IMEOffsetRune).
IMEContext string
IMEOffset int
// WindowStartByte is the absolute file byte offset where Value (the
// visible window) begins; -1/0 for whole-buffer fields. Used by the
// renderer's IME model to translate snippet positions back to file bytes
// (see Renderer.ModelTranslate).
WindowStartByte int
}
func (tf TextField) Type() string { return "textfield" }
@ -231,14 +245,6 @@ func (tf TextField) String() string {
// and draws display lines inline — one LayoutString call, no double-shaping.
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
if tf.Focused {
// (Re-)gained focus for this field (or a different field than the one the
// dedup state currently tracks): force a fresh snippet/selection push so
// the IME starts from a known state.
if r.lastIMEField != tf.id || !r.lastWasFocused {
r.lastSnippet = key.Snippet{}
r.lastSelStart = -1
r.lastSelCaret = -1
}
r.lastIMEField = tf.id
r.lastWasFocused = true
// Issue key.FocusCmd only on a focus transition (see
@ -261,81 +267,28 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
r.lastIMEShowSeq = tf.ShowIMESeq
gtx.Execute(key.SoftKeyboardCmd{Show: true})
}
// IME wiring. The visible window (tf.Value) is pushed as the snippet
// with Range {0, len}, so the IME treats the window as the document and
// reports EditEvent.Range window-relative. This lets swipe/autocorrect
// operate on the visible text without shipping the whole file to the IME.
//
// Item 4: tell the IME this is a text field (enables the text keyboard,
// Tell the IME this is a text field (enables the text keyboard,
// autocorrect, and suggestions).
key.InputHintOp{Tag: tf.id, Hint: key.HintText}.Add(gtx.Ops)
// Item 2: push the snippet (the visible window) for swipe/autocorrect,
// but only when it changed. Re-pushing an unchanged snippet every frame
// resets the IME's composition/cursor, which desyncs fast commits (see
// widget.Editor's updateSnippet dedup).
snippet := key.Snippet{
Range: key.Range{Start: 0, End: runeCount(tf.Value, len(tf.Value))},
Text: tf.Value,
}
if snippet != r.lastSnippet {
r.lastSnippet = snippet
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
}
// Item 1: sync the caret/selection so the IME's selection matches.
// Window-relative rune indices (tf.CursorPosition and the selection
// bounds are byte offsets into tf.Value). With a selection, push the
// full range so the IME highlights it and a commit replaces it (the
// logic side unions the commit range with the selection, so the
// replacement is deterministic regardless of what the IME reports).
// Push only when the (start, end) pair changes, so a static selection
// does not reset the IME every frame.
var selStart, selEnd int
if tf.SelectionStart >= 0 && tf.SelectionEnd > tf.SelectionStart {
// Clamp to the window (the element may be built from a window that
// does not fully contain the selection).
s := tf.SelectionStart
if s < 0 {
s = 0
}
e := tf.SelectionEnd
if e > len(tf.Value) {
e = len(tf.Value)
}
selStart = runeCount(tf.Value, s)
selEnd = runeCount(tf.Value, e)
} else {
selStart = -1
selEnd = runeCount(tf.Value, tf.CursorPosition)
}
if selStart != r.lastSelStart || selEnd != r.lastSelCaret {
// While a selection/caret handle drag is in progress, defer the
// IME selection sync (see Renderer.selDragActive): pushing a
// SelectionCmd every frame the drag moves the selection triggers the
// input router's immediate-command replay of the frame's pointer
// events, which re-injects the drag into every gesture and makes the
// selection jump. The final selection is pushed on the first frame
// after the drag ends (lastSelStart/lastSelCaret were not updated,
// so the mismatch persists until then).
if !r.selDragActive {
r.lastSelStart = selStart
r.lastSelCaret = selEnd
rng := key.Range{Start: selStart, End: selEnd}
if selStart < 0 {
rng = key.Range{Start: selEnd, End: selEnd}
}
gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: rng, Caret: key.Caret{}})
}
}
// The IME snippet and selection are NOT pushed here. They are pushed
// by Renderer.FlushIME, which the main loop calls after this frame's
// key events were drained (and mirrored into the renderer's IME model)
// and before the ops are flushed to the OS. Pushing the snippet from
// Draw runs before the logic has applied the frame's IME edit, so the
// commit frame would push the pre-edit text; gioui then sees the
// mismatch with the IME's own post-commit snippet and calls
// imm.restartInput() — resetting the keyboard's auto-cap state (the
// random mid-word capitals) on every keystroke. See FlushIME.
} else if r.lastIMEField == tf.id {
// This (previously-focused) field lost focus: forget it so the next focus
// pushes a fresh snippet/selection. Resetting lastIMEShowSeq makes the
// next focus re-raise the keyboard even without a fresh pulse.
// This (previously-focused) field lost focus: forget it so the next
// focus pushes a fresh snippet/selection. Resetting lastIMEShowSeq
// makes the next focus re-raise the keyboard even without a fresh
// pulse; resetting lastSnippet makes the next focus re-push the
// snippet even if the text is unchanged.
r.lastIMEField = ""
r.lastWasFocused = false
r.lastSnippet = key.Snippet{}
r.lastSelStart = -1
r.lastSelCaret = -1
r.lastIMEShowSeq = 0
r.lastSnippet = key.Snippet{}
}
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag, tf.MatchRanges, tf.CurrentMatch, tf.Focused)
}

View File

@ -110,10 +110,40 @@ type Renderer struct {
lastIMEField string
lastWasFocused bool
lastSnippet key.Snippet
lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection
lastSelCaret int // window-relative rune index of last-pushed selection end/caret
lastSelStart int // absolute rune index of last-pushed selection start; -1 = no selection
lastSelCaret int // absolute rune index of last-pushed selection end/caret
lastIMEShowSeq uint64 // last ShowIMESeq value that issued SoftKeyboardCmd{Show:true}
// IME model (main-owned, persistent across frames). The renderer keeps a
// local copy of the snippet the IME currently holds and updates it in
// place as IME edits arrive (ApplyIMEEdit / ApplyIMEKey, called from the
// key-event drain), so the snippet pushed each frame (FlushIME) is
// byte-identical to the IME's own post-commit snippet. Gioui calls
// imm.restartInput() whenever the pushed snippet differs from the IME's
// internal state (app/os_android.go EditorStateChanged); a restart makes
// the keyboard (Gboard) re-derive its auto-cap state from scratch, which
// is the root cause of the random mid-word capitals. The model is flushed
// AFTER the key events of the frame were drained and BEFORE e.Frame
// flushes the ops, so the commit frame pushes the post-edit text instead
// of the pre-edit frame text (which is what desynced the old push-from-
// Draw path and restarted the IME on every keystroke).
// Caret geometry drawn during the most recent frame, in view-local
// pixels (imeCaretPos is the caret/baseline intersection, per key.Caret).
// FlushIME attaches it to the pushed SelectionCmd: gioui only calls
// GioView.updateCaret (-> imm.updateCursorAnchorInfo, the update that
// actually reaches the IME and makes Gboard re-evaluate its auto-cap
// state) when the SelectionCmd's Caret changes. A zero caret never
// changes, so Gboard never saw a caret move and kept its stale
// auto-shift after the caret jumped (e.g. from a line start, where
// caps are legitimately on, to mid-word).
// lastIMECaretPos is the caret last shipped in a SelectionCmd.
imeCaretPos f32.Point
imeCaretAscent float32
imeCaretDescent float32
lastIMECaretPos f32.Point
// FocusCmd dedup (main-owned, persistent across frames). key.FocusCmd is
// issued ONLY on a focus transition, never per frame: even a no-op FocusCmd
// (same focus) takes the router's "immediate command" path, which re-queues
@ -233,6 +263,97 @@ func (r *Renderer) pointInHandleBox(p image.Point) bool {
return false
}
func (r *Renderer) FlushIME(gtx layout.Context, tf TextField) {
ctxRunes := utf8.RuneCountInString(tf.IMEContext)
// The snippet is the visible window with up to 10 runes of leading
// context prepended, addressed from the absolute file rune where the
// context begins (tf.IMEOffset): the IME sees the true document
// position, and Android's TextUtils.getCapsMode
// (GioView.getCursorCapsMode) can find real preceding context instead of
// treating the top of the visible window as the start of a sentence.
//
// The snippet is pushed whenever the frame's text differs from the last
// push (window moved, edit applied, file switched). gioui dedupes a
// SnippetCmd against its own cache, so only real changes reach the OS as
// imm.restartInput. After a commit the frame text (the buffer with the
// commit applied) equals what the IME already holds locally, so the
// dedupe naturally suppresses the restart; a fling moves the window and
// re-anchors the IME exactly once per text change. Commit positions
// arriving while a restart is dropped (Gboard during flings) are caught
// by the logic-side drift guard (editor.HandleIMECommit), which snaps a
// small commit to the cursor — the position the IME was last told.
snippet := key.Snippet{
Range: key.Range{
Start: tf.IMEOffset,
End: tf.IMEOffset + ctxRunes + utf8.RuneCountInString(tf.Value),
},
Text: tf.IMEContext + tf.Value,
}
if snippet != r.lastSnippet {
r.lastSnippet = snippet
// The snippet change resets the IME's selection: force the
// selection re-push below so the caret is re-anchored in the new
// text in the same frame.
r.lastSelStart, r.lastSelCaret = -1, -1
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
}
// Push the caret/selection (deduped) in absolute file runes, the same
// coordinate space as the snippet (see widget.Editor's updateIMEState).
var selStart, selEnd int
if tf.SelectionStart >= 0 && tf.SelectionEnd > tf.SelectionStart {
s, e := tf.SelectionStart, tf.SelectionEnd
if s < 0 {
s = 0
}
if e > len(tf.Value) {
e = len(tf.Value)
}
selStart = tf.IMEOffset + ctxRunes + runeCount(tf.Value, s)
selEnd = tf.IMEOffset + ctxRunes + runeCount(tf.Value, e)
} else {
selStart = -1
selEnd = tf.IMEOffset + ctxRunes + runeCount(tf.Value, tf.CursorPosition)
}
selectionJumped := selStart != r.lastSelStart || selEnd != r.lastSelCaret
caretMoved := r.imeCaretPos != r.lastIMECaretPos
if selectionJumped || caretMoved {
// Skip while a selection/caret handle drag is in flight (see
// Renderer.selDragActive): a mid-gesture updateSelection makes the
// IME's selection jump and aborts the gesture. key.SelDragActive is
// stale here (renderer-owned), so use the renderer's own flag.
if !r.selDragActive {
r.lastSelStart = selStart
r.lastSelCaret = selEnd
r.lastIMECaretPos = r.imeCaretPos
rng := key.Range{Start: selStart, End: selEnd}
if selStart < 0 {
rng = key.Range{Start: selEnd, End: selEnd}
}
// The SelectionCmd must reach the IME on every caret jump: it is
// the signal (GioView.updateSelection -> imm.updateSelection,
// delivered as onUpdateSelection) that makes Gboard re-derive
// its auto-shift state from the new position — mid-word: off;
// line/sentence start: on. It must NOT be shadowed by a snippet
// push in the same frame: a snippet change makes gioui's
// EditorStateChanged take the restartInput branch instead (which
// Gboard does not use to re-derive caps), so a caret jump with an
// unchanged snippet is exactly the case that works.
// The Caret field carries the real position (view-local px, Pos
// on the baseline) so gioui also fires updateCaret
// (imm.updateCursorAnchorInfo) for the jump.
gtx.Execute(key.SelectionCmd{
Tag: tf.id,
Range: rng,
Caret: key.Caret{
Pos: r.imeCaretPos,
Ascent: r.imeCaretAscent,
Descent: r.imeCaretDescent,
},
})
}
}
}
// New creates a new Renderer.
func New(th Theme, shp *text.Shaper) *Renderer {
r := &Renderer{
@ -1095,9 +1216,19 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
// byte and len(str) is the window's last insertion point.
if focused && cursorPos >= 0 && cursorPos <= len(str) {
// Determine cursor position from `layout` and `cursorPos`
cursorX, cursorY := caretPoint(cursorPos)
cursorX = reg.X + cursorX
cursorY = reg.Y - scrollOffset + cursorY - ascent
caretX, caretY := caretPoint(cursorPos) // y is the line's baseline, region-relative
cursorX := reg.X + caretX
cursorY := reg.Y - scrollOffset + caretY - ascent
// Save the caret geometry (view-local px; Pos on the baseline) for
// FlushIME's SelectionCmd so the IME is told where the caret moved.
// See imeCaretPos.
r.imeCaretPos = f32.Point{
X: float32(r.toPx(cursorX)),
Y: float32(r.toPx(reg.Y - scrollOffset + caretY)),
}
r.imeCaretAscent = float32(r.toPx(ascent))
r.imeCaretDescent = float32(r.toPx(lineH - ascent))
// Draw the cursor (thin vertical bar)
cursorRegion := Region{

195
scripts/make_icon.py Normal file
View File

@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Generate cmd/pad/appicon.png, the Pad app icon.
Design: a soft, light cream pillow on a dark ink field, with "P_" (the
underscore standing in for the text cursor) embroidered on it in slate
thread. gogio picks appicon.png up automatically from the main package
(cmd/pad) and builds the Android mipmap/adaptive-icon set and the iOS
icon set from it.
Usage: scripts/make_icon.py
Requires: python3 with PIL.
"""
import pathlib
from PIL import Image, ImageDraw, ImageFilter, ImageFont
SIZE = 1024
INK = (32, 38, 50) # background
PILLOW = (243, 231, 211) # base fabric
PILLOW_LIT = (252, 246, 234)
PILLOW_RIM = (206, 187, 158)
THREAD = (92, 104, 134) # embroidered slate thread
THREAD_DARK = (58, 67, 92)
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
def rrect_mask(w, h, r):
m = Image.new("L", (w, h), 0)
ImageDraw.Draw(m).rounded_rectangle([0, 0, w - 1, h - 1], radius=r, fill=255)
return m
def main() -> None:
img = Image.new("RGB", (SIZE, SIZE), INK)
# --- pillow geometry (kept inside the adaptive-icon safe zone) ---
pw, ph, pr = 672, 612, 195 # pillow size + corner radius
px0, py0 = (SIZE - pw) // 2, (SIZE - ph) // 2 + 6
# --- soft drop shadow (slightly below, so light comes from above) ---
shadow = Image.new("L", (SIZE, SIZE), 0)
d = ImageDraw.Draw(shadow)
d.rounded_rectangle([px0 + 16, py0 + 30, px0 + pw - 16, py0 + ph + 36],
radius=pr + 10, fill=120)
shadow = shadow.filter(ImageFilter.GaussianBlur(42))
img = Image.composite(Image.new("RGB", (SIZE, SIZE), (18, 21, 29)), img,
shadow)
# --- pillow body: plump radial shading ---
base = Image.new("RGB", (SIZE, SIZE), INK)
base.paste(Image.new("RGB", (pw, ph), PILLOW), (px0, py0))
sharp = rrect_mask(pw, ph, pr)
inner = sharp.filter(ImageFilter.GaussianBlur(pr * 0.8))
lit = Image.new("RGB", (pw, ph), PILLOW_LIT)
rim = Image.new("RGB", (pw, ph), PILLOW_RIM)
base.paste(lit, (px0, py0), inner) # bright center
rim_mask = Image.composite(Image.new("L", (pw, ph), 0), sharp,
inner) # edge only
rim_mask = rim_mask.point(lambda v: min(255, v * 2))
base.paste(rim, (px0, py0), rim_mask) # shaded edge
# top sheen: an out-of-focus light patch
sheen = Image.new("L", (SIZE, SIZE), 0)
d = ImageDraw.Draw(sheen)
d.ellipse([px0 + 110, py0 + 30, px0 + pw - 110, py0 + ph // 2 + 40],
fill=80)
sheen = sheen.filter(ImageFilter.GaussianBlur(75))
base = Image.composite(Image.new("RGB", (SIZE, SIZE), PILLOW_LIT),
base, sheen)
# corner dimples: soft darkening where a puffed pillow pinches
for cx, cy in [
(px0 + pr - 20, py0 + pr - 20), (px0 + pw - pr + 20, py0 + pr - 20),
(px0 + pr - 20, py0 + ph - pr + 20),
(px0 + pw - pr + 20, py0 + ph - pr + 20),
]:
dip = Image.new("L", (SIZE, SIZE), 0)
ImageDraw.Draw(dip).ellipse([cx - 70, cy - 70, cx + 70, cy + 70],
fill=55)
dip = dip.filter(ImageFilter.GaussianBlur(30))
base = Image.composite(
Image.blend(base, Image.new("RGB", (SIZE, SIZE),
(214, 196, 168)), 0.5),
base, dip)
# --- dashed seam line, like a quilted pillow ---
# Sample the rounded-rect path finely, then draw dash segments along it.
m = 44 # margin from the pillow edge
r = pr - m # corner radius of the seam
import math as _m
# Corners clockwise: center, angle start -> end (screen coords, y down).
# Each arc is followed by the straight side to the next arc's start.
arcs = [
(m + r, m + r, _m.pi, 1.5 * _m.pi), # top-left
(pw - m - r, m + r, 1.5 * _m.pi, 2 * _m.pi), # top-right
(pw - m - r, ph - m - r, 0, 0.5 * _m.pi), # bottom-right
(m + r, ph - m - r, 0.5 * _m.pi, _m.pi), # bottom-left
]
samples = []
for i, (cx, cy, a0, a1) in enumerate(arcs):
nx, ny, na0, _ = arcs[(i + 1) % 4]
n = max(2, int(abs(a1 - a0) * r // 3))
for k in range(n + 1):
a = a0 + (a1 - a0) * k / n
samples.append((cx + r * _m.cos(a), cy + r * _m.sin(a)))
# straight side from this arc's end to the next arc's start
end = (nx + r * _m.cos(na0), ny + r * _m.sin(na0))
last = samples[-1]
n = max(2, int(_m.hypot(end[0] - last[0], end[1] - last[1]) // 3))
for k in range(1, n + 1):
samples.append((last[0] + (end[0] - last[0]) * k / n,
last[1] + (end[1] - last[1]) * k / n))
# cumulative arc length
cum = [0.0]
for i in range(1, len(samples)):
cum.append(cum[-1] + _m.hypot(samples[i][0] - samples[i - 1][0],
samples[i][1] - samples[i - 1][1]))
total = cum[-1]
dash, gap = 26.0, 18.0
seam_ring = Image.new("L", (pw, ph), 0)
d = ImageDraw.Draw(seam_ring)
s0 = 0.0
while s0 < total:
s1 = min(s0 + dash, total)
pts = [(x, y) for (x, y), c in zip(samples, cum) if s0 <= c <= s1]
if len(pts) > 1:
d.line(pts, fill=255, width=9, joint="curve")
s0 += dash + gap
seam_ring = seam_ring.filter(ImageFilter.GaussianBlur(1.2))
seam = seam_ring
seam_full = Image.new("L", (SIZE, SIZE), 0)
seam_full.paste(seam, (px0, py0))
seam_col = Image.new("RGB", (SIZE, SIZE), (203, 183, 154))
base = Image.composite(seam_col, base, seam_full)
# composite pillow onto background via its sharp mask
full_sharp = Image.new("L", (SIZE, SIZE), 0)
full_sharp.paste(sharp, (px0, py0))
img.paste(base, (0, 0), full_sharp)
# --- embroidered "P_" ---
# Center the CAP of the P on the pillow (optical center); the
# underscore hangs below the baseline like a cursor on the next line.
font = ImageFont.truetype(FONT, 300)
# Drawing "P" at (x, y) puts its cap top at y + pb[1]; center the cap
# on the pillow, keep the whole "P_" string on the same baseline.
probe = ImageDraw.Draw(img)
pb = probe.textbbox((0, 0), "P", font=font)
cb_h = pb[3] - pb[1] # cap height
ty = SIZE // 2 - cb_h // 2 - pb[1]
tb = probe.textbbox((0, 0), "P_", font=font)
tw, th = tb[2] - tb[0], tb[3] - tb[1]
tx = (SIZE - tw) // 2 - tb[0]
# stitch texture: fine diagonal thread lines, tiled
tile = 6
pat = Image.new("L", (tile, tile), 160)
ImageDraw.Draw(pat).line([(0, tile - 1), (tile - 1, 0)], fill=255,
width=1)
pat = pat.filter(ImageFilter.GaussianBlur(0.5))
mw, mh = tw + 40, tb[3] + 80
stitch = pat.resize((mw, mh), Image.NEAREST)
# text mask in local coords
# local (0,0) == the absolute drawing origin (tx, ty); paste happens at
# (tx-20, ty-20), so text goes at (20 - tb[0], 20).
mask = Image.new("L", (mw, mh), 0)
ImageDraw.Draw(mask).text((20 - tb[0], 20), "P_", font=font,
fill=255)
# drop shadow of the stitches on the fabric (thread sits on top of it)
sh = mask.filter(ImageFilter.GaussianBlur(5))
sh_full = Image.new("L", (SIZE, SIZE), 0)
sh_full.paste(sh, (tx - 20, ty - 20 + 7))
img = Image.composite(Image.new("RGB", (SIZE, SIZE), (182, 164, 140)),
img, sh_full)
# thread color modulated by the stitch pattern (subtle per-stitch depth)
stitch_full = Image.new("L", (SIZE, SIZE), 0)
stitch_full.paste(stitch, (tx - 20, ty - 20))
mod = Image.composite(Image.new("RGB", (SIZE, SIZE), THREAD_DARK),
Image.new("RGB", (SIZE, SIZE), THREAD),
stitch_full)
mask_full = Image.new("L", (SIZE, SIZE), 0)
mask_full.paste(mask, (tx - 20, ty - 20))
img = Image.composite(mod, img, mask_full)
out = pathlib.Path(__file__).resolve().parent.parent / "cmd" / "pad" / "appicon.png"
img.save(out)
print(f"wrote {out} ({SIZE}x{SIZE})")
if __name__ == "__main__":
main()