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.
348 lines
12 KiB
Go
348 lines
12 KiB
Go
package editor
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"pad/internal/io/pool/mock"
|
|
)
|
|
|
|
// newStringState builds a minimal State that uses the (small-file) string
|
|
// buffer fallback rather than a ChunkedBuffer.
|
|
func newStringState(buf string) *State {
|
|
st := NewState()
|
|
TheState = st
|
|
st.Editor.Buffer = buf
|
|
st.Editor.ChunkedBuffer = nil
|
|
st.Editor.CursorPosition = len(buf)
|
|
return st
|
|
}
|
|
|
|
// newChunkedState builds a minimal State backed by a ChunkedBuffer over a
|
|
// mock filesystem. chunkSize is small so a short file spans several chunks.
|
|
func newChunkedState(t *testing.T, content string, chunkSize int) *State {
|
|
t.Helper()
|
|
mockFS := mock.NewFileSystem()
|
|
filename := "/ime_range.txt"
|
|
mockFS.AddFile(filename, []byte(content), time.Now())
|
|
cb := NewChunkedBuffer(filename, chunkSize, mockFS, "")
|
|
// Full-load via the on-open path (all chunks resident), matching production.
|
|
cb.SetContent([]byte(content))
|
|
st := NewState()
|
|
TheState = st
|
|
st.Editor.Filename = filename
|
|
st.Editor.ChunkedBuffer = cb
|
|
st.Editor.Buffer = ""
|
|
st.Editor.CursorPosition = len(content)
|
|
return st
|
|
}
|
|
|
|
func TestRuneIndexToByteStr(t *testing.T) {
|
|
cases := []struct {
|
|
s string
|
|
n int
|
|
want int
|
|
}{
|
|
{"", 0, 0},
|
|
{"abc", 0, 0},
|
|
{"abc", 1, 1},
|
|
{"abc", 3, 3},
|
|
{"abc", 10, 3}, // past end -> len(s)
|
|
// "héllo": h(1) é(2) l(1) l(1) o(1) -> byte offsets 0,1,3,4,5
|
|
{"héllo", 0, 0},
|
|
{"héllo", 1, 1}, // after 'h'
|
|
{"héllo", 2, 3}, // after 'é' (2 bytes)
|
|
{"héllo", 5, 6}, // end
|
|
// "日本語": each rune is 3 bytes
|
|
{"日本語", 0, 0},
|
|
{"日本語", 1, 3},
|
|
{"日本語", 2, 6},
|
|
{"日本語", 3, 9},
|
|
}
|
|
for _, c := range cases {
|
|
if got := runeIndexToByteStr(c.s, c.n); got != c.want {
|
|
t.Errorf("runeIndexToByteStr(%q, %d) = %d, want %d", c.s, c.n, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRuneIndexToByte_Chunked(t *testing.T) {
|
|
// Use a 4-byte chunk size so "hello world" (11 bytes) spans 3 chunks.
|
|
st := newChunkedState(t, "hello world", 4)
|
|
cb := st.Editor.ChunkedBuffer
|
|
|
|
// All ASCII: rune index == byte offset.
|
|
for n := 0; n <= 11; n++ {
|
|
if got := cb.RuneIndexToByte(n); got != n {
|
|
t.Errorf("ASCII RuneIndexToByte(%d) = %d, want %d", n, got, n)
|
|
}
|
|
}
|
|
|
|
// Non-ASCII spanning chunk boundaries. "abéfg" = a(1) b(1) é(2) f(1) g(1)
|
|
// = 6 bytes; rune byte offsets are 0,1,2,4,5 and the end is 6.
|
|
st2 := newChunkedState(t, "abéfg", 2)
|
|
cb2 := st2.Editor.ChunkedBuffer
|
|
want := []int{0, 1, 2, 4, 5, 6}
|
|
for n, w := range want {
|
|
if got := cb2.RuneIndexToByte(n); got != w {
|
|
t.Errorf("RuneIndexToByte(%d) = %d, want %d (s=\"abéfg\", chunk=2)", n, got, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Insert_String(t *testing.T) {
|
|
st := newStringState("Hello World")
|
|
// Insert at rune 5 (byte 5, the space) -> "Hello World"
|
|
HandleReplaceRange(5, 5, "!")
|
|
// Insert before the space: "Hello" + "!" + " World"
|
|
want := "Hello! World"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// Cursor should be at the end of the inserted text: startByte(5) + len("!")
|
|
if st.Editor.CursorPosition != 6 {
|
|
t.Fatalf("cursor = %d, want 6", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Replace_String(t *testing.T) {
|
|
// This is the core of item 3: a swipe/autocorrect commit replaces the
|
|
// selected region without duplicating it.
|
|
st := newStringState("Hello World")
|
|
// Replace runes [6,11) == "World" with "there"
|
|
HandleReplaceRange(6, 11, "there")
|
|
want := "Hello there"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// Cursor at end of inserted text: startByte(6) + len("there") = 11
|
|
if st.Editor.CursorPosition != 11 {
|
|
t.Fatalf("cursor = %d, want 11", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Delete_String(t *testing.T) {
|
|
st := newStringState("Hello World")
|
|
// Delete runes [5,11) == " World" (empty text)
|
|
HandleReplaceRange(5, 11, "")
|
|
want := "Hello"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
if st.Editor.CursorPosition != 5 {
|
|
t.Fatalf("cursor = %d, want 5", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Unicoded_String(t *testing.T) {
|
|
// "héllo wörld": replace the 2nd word's runes.
|
|
// runes: h(0) é(1) l(2) l(3) o(4) ' '(5) w(6) ö(7) r(8) l(9) d(10)
|
|
st := newStringState("héllo wörld")
|
|
// Replace runes [6,11) == "wörld" with "there"
|
|
HandleReplaceRange(6, 11, "there")
|
|
want := "héllo there"
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
// startByte for rune 6 = 7 (h=1,é=2,l=1,l=1,o=1,' '=1 -> 7 bytes), + len("there")=5
|
|
if st.Editor.CursorPosition != 12 {
|
|
t.Fatalf("cursor = %d, want 12", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Replace_Chunked(t *testing.T) {
|
|
// Chunked path: replace a region that spans multiple small chunks.
|
|
content := "Hello World, this is a test."
|
|
st := newChunkedState(t, content, 8)
|
|
cb := st.Editor.ChunkedBuffer
|
|
|
|
// Replace runes [12,15) == "this"[0:3]="thi"? let's pick [5,11) = " World"
|
|
HandleReplaceRange(5, 11, " there")
|
|
want := "Hello there, this is a test."
|
|
got, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("buffer = %q, want %q", got, want)
|
|
}
|
|
// startByte for rune 5 = 5 (ASCII), + len(" there") = 6 -> 11
|
|
if st.Editor.CursorPosition != 11 {
|
|
t.Fatalf("cursor = %d, want 11", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
func TestHandleReplaceRange_Unicode_Chunked(t *testing.T) {
|
|
// Non-ASCII content in a chunked buffer, replacing across rune/byte
|
|
// mismatch. "héllo" -> replace rune 1 ('é') with 'e'.
|
|
st := newChunkedState(t, "héllo", 2)
|
|
cb := st.Editor.ChunkedBuffer
|
|
HandleReplaceRange(1, 2, "e")
|
|
want := "hello"
|
|
got, err := cb.FullContent()
|
|
if err != nil {
|
|
t.Fatalf("FullContent: %v", err)
|
|
}
|
|
if got != want {
|
|
t.Fatalf("buffer = %q, want %q", got, want)
|
|
}
|
|
// startByte for rune 1 = 1, + len("e") = 1 -> 2
|
|
if st.Editor.CursorPosition != 2 {
|
|
t.Fatalf("cursor = %d, want 2", st.Editor.CursorPosition)
|
|
}
|
|
}
|
|
|
|
// TestHandleReplaceRange_SwappedBounds guards against start>end (the IME can
|
|
// deliver either order); it must normalize and behave as [min, max).
|
|
func TestHandleReplaceRange_SwappedBounds(t *testing.T) {
|
|
st := newStringState("abcdef")
|
|
// Provide swapped bounds for range [1,3) ("bc") -> replace with "XY"
|
|
HandleReplaceRange(3, 1, "XY")
|
|
want := "aXYdef"
|
|
if !strings.Contains(st.Editor.Buffer, "XY") {
|
|
t.Fatalf("buffer = %q, want to contain %q", st.Editor.Buffer, "XY")
|
|
}
|
|
if st.Editor.Buffer != want {
|
|
t.Fatalf("buffer = %q, want %q", st.Editor.Buffer, want)
|
|
}
|
|
}
|
|
|
|
// TestHandleReplaceRange_WindowOffset_Chunked verifies the scrolled-viewport
|
|
// 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."), with no leading context
|
|
// (all ASCII, so rune offset == byte offset).
|
|
TheState.Editor.IMEWindowStartByte = 6
|
|
TheState.Editor.IMEWindowText = "World, this is a test."
|
|
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 {
|
|
t.Fatal("FullContent failed: ", err)
|
|
}
|
|
if want := "Hello There, this is a test."; got != want {
|
|
t.Fatalf("windowed replace: got %q, want %q", got, want)
|
|
}
|
|
// Cursor: window start (6) + len("There") (5) = 11.
|
|
if cp := TheState.Editor.CursorPosition; cp != 11 {
|
|
t.Fatalf("cursor = %d, want 11", cp)
|
|
}
|
|
}
|
|
|
|
// 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."
|
|
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 := 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, "")
|
|
}
|