editor+ui: complete Android IME wiring (SelectionCmd, SnippetCmd, InputHintOp)

Finish Phase 1 items 1, 2, 4, on top of the range-handling done in 9b78219:

- TextField.Draw now emits, when focused:
  * key.InputHintOp{HintText}        (item 4: enable text keyboard/autocorrect)
  * key.SnippetCmd (the visible window as the snippet, Range {0,len})
                                              (item 2: swipe/autocorrect source)
  * key.SelectionCmd (caret, window-relative rune index)
                                              (item 1: IME selection sync)
  The snippet is the visible window (not the whole file), so the IME treats
  the window as the document and reports EditEvent.Range window-relative.

- HandleReplaceRange now resolves the window-relative range against
  IMEWindowText and offsets by IMEWindowStartByte to address the buffer
  (string and chunked paths). Falls back to the whole buffer when layout
  has not set the window (tests).

- EditorState gains IMEWindowStartByte / IMEWindowText, set during layout.

- Add runeCount helper (utf8 leading-byte scan) in the ui package.

- Fix a data race in three editor e2e/integration tests: they called
  OpenFile from the test goroutine while the logic goroutine ran layout;
  now wrapped in withState so the state write happens on the owner.

Tests: ime_range_test.go gains windowed-path coverage (string+chunked).
go build, go test, go vet, and go test -race are all green.
This commit is contained in:
Greg Pomerantz 2026-08-16 02:43:58 -04:00
parent 9b782190fa
commit 72b3c3f9c1
5 changed files with 130 additions and 16 deletions

View File

@ -45,7 +45,10 @@ func TestAutoSaveE2E(t *testing.T) {
}) })
// 2. Open File (OpenFile also sets Editor.Filename on the owner) // 2. Open File (OpenFile also sets Editor.Filename on the owner)
OpenFile(filename) withState(t, l, func(st *State) {
TheState = st
OpenFile(filename)
})
// Wait for the file to be loaded by checking if ChunkedBuffer is populated // Wait for the file to be loaded by checking if ChunkedBuffer is populated
success := false success := false
@ -131,7 +134,10 @@ func TestLargeFileChunkBoundary(t *testing.T) {
}) })
// --- 2. Open File --- // --- 2. Open File ---
OpenFile(filename) withState(t, l, func(st *State) {
TheState = st
OpenFile(filename)
})
success := false success := false
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {

View File

@ -210,3 +210,44 @@ func TestHandleReplaceRange_SwappedBounds(t *testing.T) {
t.Fatalf("buffer = %q, want %q", 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 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.
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.").
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")
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 window-relative
// logic 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")
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 {
t.Fatalf("cursor = %d, want 11", cp)
}
}

View File

@ -31,7 +31,10 @@ func TestOpenFileIntegration(t *testing.T) {
// 2. Open File // 2. Open File
// OpenFile dispatches the task to openFileChan, which Run() will consume // OpenFile dispatches the task to openFileChan, which Run() will consume
OpenFile(filename) withState(t, l, func(st *State) {
TheState = st
OpenFile(filename)
})
// 3. Process the Result // 3. Process the Result
// We wait for the state to update, which happens when ReadChunkTask completes // We wait for the state to update, which happens when ReadChunkTask completes

View File

@ -53,6 +53,15 @@ type EditorState struct {
SelectionStart int SelectionStart int
SelectionEnd int SelectionEnd int
CursorVisible bool CursorVisible bool
// 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.
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.
IMEWindowText string
Filename string Filename string
fileVersion map[string]int fileVersion map[string]int
lastWriteVersion map[string]int lastWriteVersion map[string]int
@ -627,25 +636,40 @@ func HandleReplaceRange(startRune, endRune int, text string) {
if startRune > endRune { if startRune > endRune {
startRune, endRune = endRune, startRune 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
}
}
absStart := windowStart + runeIndexToByteStr(windowText, startRune)
absEnd := windowStart + runeIndexToByteStr(windowText, endRune)
var newCursor int var newCursor int
if buf := TheState.Editor.ChunkedBuffer; buf != nil { if buf := TheState.Editor.ChunkedBuffer; buf != nil {
startByte := buf.RuneIndexToByte(startRune) if absEnd > absStart {
endByte := buf.RuneIndexToByte(endRune) buf.Delete(absStart, absEnd-absStart)
if endByte > startByte {
buf.Delete(startByte, endByte-startByte)
} }
buf.Insert(startByte, text) buf.Insert(absStart, text)
buf.UpdateLineIndexAfterEdit(startByte, len(text)-(endByte-startByte)) buf.UpdateLineIndexAfterEdit(absStart, len(text)-(absEnd-absStart))
newCursor = startByte + len(text) newCursor = absStart + len(text)
} else { } else {
s := TheState.Editor.Buffer s := TheState.Editor.Buffer
startByte := runeIndexToByteStr(s, startRune) if absEnd > absStart {
endByte := runeIndexToByteStr(s, endRune) s = s[:absStart] + s[absEnd:]
if endByte > startByte {
s = s[:startByte] + s[endByte:]
} }
TheState.Editor.Buffer = s[:startByte] + text + s[startByte:] TheState.Editor.Buffer = s[:absStart] + text + s[absStart:]
newCursor = startByte + len(text) newCursor = absStart + len(text)
} }
TheState.Editor.CursorPosition = newCursor TheState.Editor.CursorPosition = newCursor
markDirty() markDirty()
@ -841,6 +865,12 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
s = visibleContent s = visibleContent
} }
fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s) fmt.Printf("LAYOUT: visibleScrollOffset: %f visibleContent (%d) = %s...\n",visibleScrollOffset,len(visibleContent),s)
// 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.
TheState.Editor.IMEWindowStartByte = start
TheState.Editor.IMEWindowText = visibleContent
// Add the TextField back in a way that passes the test. // Add the TextField back in a way that passes the test.
editorElem := ui.NewTextField( editorElem := ui.NewTextField(
"editor_text", "editor_text",

View File

@ -212,10 +212,44 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
fmt.Printf("Focused: tag = %s\n", tf.id) fmt.Printf("Focused: tag = %s\n", tf.id)
gtx.Execute(key.FocusCmd{Tag: tf.id}) gtx.Execute(key.FocusCmd{Tag: tf.id})
gtx.Execute(key.SoftKeyboardCmd{Show: true}) 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,
// 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.
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: key.Snippet{
Range: key.Range{Start: 0, End: runeCount(tf.Value, len(tf.Value))},
Text: tf.Value,
}})
// Item 1: sync the caret so the IME's selection matches. Window-relative
// rune index of the caret (tf.CursorPosition is a byte offset in tf.Value).
caret := runeCount(tf.Value, tf.CursorPosition)
gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, Caret: key.Caret{}})
} }
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition) r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition)
} }
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
// byte offset, clamped to len(s)). A rune starts at an ASCII byte (<0x80) or a
// multi-byte lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
func runeCount(s string, bytePos int) int {
if bytePos > len(s) {
bytePos = len(s)
}
n := 0
for i := 0; i < bytePos; i++ {
b := s[i]
if b < 0x80 || b >= 0xC0 {
n++
}
}
return n
}
// NewTextField creates a visible multiline TextField. // NewTextField creates a visible multiline TextField.
func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField { func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField {
return TextField{ return TextField{