diff --git a/internal/editor/e2e_test.go b/internal/editor/e2e_test.go index 230aa98..14d9019 100644 --- a/internal/editor/e2e_test.go +++ b/internal/editor/e2e_test.go @@ -45,7 +45,10 @@ func TestAutoSaveE2E(t *testing.T) { }) // 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 success := false @@ -131,7 +134,10 @@ func TestLargeFileChunkBoundary(t *testing.T) { }) // --- 2. Open File --- - OpenFile(filename) + withState(t, l, func(st *State) { + TheState = st + OpenFile(filename) + }) success := false for i := 0; i < 20; i++ { diff --git a/internal/editor/ime_range_test.go b/internal/editor/ime_range_test.go index e391606..e2157df 100644 --- a/internal/editor/ime_range_test.go +++ b/internal/editor/ime_range_test.go @@ -210,3 +210,44 @@ func TestHandleReplaceRange_SwappedBounds(t *testing.T) { 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) + } +} diff --git a/internal/editor/integration_test.go b/internal/editor/integration_test.go index 3b58e2c..e028147 100644 --- a/internal/editor/integration_test.go +++ b/internal/editor/integration_test.go @@ -31,7 +31,10 @@ func TestOpenFileIntegration(t *testing.T) { // 2. Open File // 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 // We wait for the state to update, which happens when ReadChunkTask completes diff --git a/internal/editor/state.go b/internal/editor/state.go index 946a88e..038c6a8 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -53,6 +53,15 @@ type EditorState struct { SelectionStart int SelectionEnd int 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 fileVersion map[string]int lastWriteVersion map[string]int @@ -627,25 +636,40 @@ 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 + } + } + absStart := windowStart + runeIndexToByteStr(windowText, startRune) + absEnd := windowStart + runeIndexToByteStr(windowText, endRune) + var newCursor int if buf := TheState.Editor.ChunkedBuffer; buf != nil { - startByte := buf.RuneIndexToByte(startRune) - endByte := buf.RuneIndexToByte(endRune) - if endByte > startByte { - buf.Delete(startByte, endByte-startByte) + if absEnd > absStart { + buf.Delete(absStart, absEnd-absStart) } - buf.Insert(startByte, text) - buf.UpdateLineIndexAfterEdit(startByte, len(text)-(endByte-startByte)) - newCursor = startByte + len(text) + buf.Insert(absStart, text) + buf.UpdateLineIndexAfterEdit(absStart, len(text)-(absEnd-absStart)) + newCursor = absStart + len(text) } else { s := TheState.Editor.Buffer - startByte := runeIndexToByteStr(s, startRune) - endByte := runeIndexToByteStr(s, endRune) - if endByte > startByte { - s = s[:startByte] + s[endByte:] + if absEnd > absStart { + s = s[:absStart] + s[absEnd:] } - TheState.Editor.Buffer = s[:startByte] + text + s[startByte:] - newCursor = startByte + len(text) + TheState.Editor.Buffer = s[:absStart] + text + s[absStart:] + newCursor = absStart + len(text) } TheState.Editor.CursorPosition = newCursor markDirty() @@ -841,6 +865,12 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { s = visibleContent } 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. editorElem := ui.NewTextField( "editor_text", diff --git a/internal/ui/element.go b/internal/ui/element.go index 48768ae..45872c8 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -212,10 +212,44 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { fmt.Printf("Focused: tag = %s\n", tf.id) gtx.Execute(key.FocusCmd{Tag: tf.id}) 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) } +// 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. func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField { return TextField{