diff --git a/cmd/pad/appicon.png b/cmd/pad/appicon.png new file mode 100644 index 0000000..e96d91b Binary files /dev/null and b/cmd/pad/appicon.png differ diff --git a/cmd/pad/main.go b/cmd/pad/main.go index b27b44e..6f73450 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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} } diff --git a/go.mod b/go.mod index 9e5afab..60b5f4e 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/internal/editor/frame.go b/internal/editor/frame.go index 77d2718..2e91ff5 100644 --- a/internal/editor/frame.go +++ b/internal/editor/frame.go @@ -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 diff --git a/internal/editor/ime_range_test.go b/internal/editor/ime_range_test.go index 49fb886..44627ac 100644 --- a/internal/editor/ime_range_test.go +++ b/internal/editor/ime_range_test.go @@ -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, "") +} diff --git a/internal/editor/logic.go b/internal/editor/logic.go index fd7e6da..fe33780 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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) diff --git a/internal/editor/state.go b/internal/editor/state.go index ccd3ee3..4d7cc99 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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) } } diff --git a/internal/io/pool/real/filesystem.go b/internal/io/pool/real/filesystem.go index 813661c..82198aa 100644 --- a/internal/io/pool/real/filesystem.go +++ b/internal/io/pool/real/filesystem.go @@ -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 { diff --git a/internal/test/e2e/real_file_fuzz_test.go b/internal/test/e2e/real_file_fuzz_test.go index 1d32f34..1d07864 100644 --- a/internal/test/e2e/real_file_fuzz_test.go +++ b/internal/test/e2e/real_file_fuzz_test.go @@ -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:] } } diff --git a/internal/test/e2e/real_file_large_ime_test.go b/internal/test/e2e/real_file_large_ime_test.go new file mode 100644 index 0000000..ae0c4aa --- /dev/null +++ b/internal/test/e2e/real_file_large_ime_test.go @@ -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 +} diff --git a/internal/test/e2e/real_file_scroll_selection_test.go b/internal/test/e2e/real_file_scroll_selection_test.go index 7af42b2..d04642e 100644 --- a/internal/test/e2e/real_file_scroll_selection_test.go +++ b/internal/test/e2e/real_file_scroll_selection_test.go @@ -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", }, }}) diff --git a/internal/test/e2e/restore_test.go b/internal/test/e2e/restore_test.go index e0cd2ab..e4ec134 100644 --- a/internal/test/e2e/restore_test.go +++ b/internal/test/e2e/restore_test.go @@ -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). diff --git a/internal/ui/element.go b/internal/ui/element.go index a962a49..ce99597 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -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) } diff --git a/internal/ui/render.go b/internal/ui/render.go index 19838ed..ef65bef 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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{ diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..e9e72cc --- /dev/null +++ b/scripts/make_icon.py @@ -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()