diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 6e063d7..5fd3459 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -66,6 +66,13 @@ func run(w *app.Window) error { var perfOn bool var presentN int var presentStart time.Time + // shiftDown tracks the hardware shift key on the MAIN goroutine. + // On Android Gio's JNI bridge drops modifier state (GioView.onKeyEvent + // never reads event.getMetaState), so key.Event.Modifiers is always 0 and + // shift+arrow is indistinguishable from a plain arrow. Gio does deliver + // the NameShift press/release as plain key.Events, so we track them here + // and attach the state to the ui.KeyEvent we forward. + var shiftDown bool if _, err := os.Stat(filepath.Join(perfDir, "enable")); err == nil { prof = perf.New(true, perfDir, "logic_frames.csv") editor.PerfRecord = func(rec editor.ProbeRecord) { @@ -144,24 +151,55 @@ func run(w *app.Window) error { // Gather key events focusedID := frame.FocusedElementID + if focusedID == "" { + // No focused input: drop any stale shift state (a release event + // for a key held across focus loss may never arrive). + shiftDown = false + } if focusedID != "" { if reg, ok := renderer.Keys[focusedID]; ok { // Use key.Filter to only receive events destined for the focused element. // We need both Key events (for arrow keys) and Edit events (for text input). // In Gio, key.Filter covers key presses, while key.FocusFilter covers // focus and text edit events. + // + // On mobile the window layer wraps plain arrow-key PRESSES in + // input.SystemEvent (it wants to use them for focus navigation) and + // such events match only filters that name the key explicitly. Querying + // the four arrow names below therefore (a) makes the presses deliverable + // and (b) suppresses the focus-move side effect: a matched event makes + // WakeupTime report handled, which skips the window's moveFocus call. for { // Filter for key events and focus/edit events targeted at focusedID - evt, ok := gtx.Event(key.Filter{Focus: focusedID}, key.FocusFilter{Target: focusedID}) + evt, ok := gtx.Event( + key.Filter{Focus: focusedID}, + key.Filter{Focus: focusedID, Name: key.NameLeftArrow}, + key.Filter{Focus: focusedID, Name: key.NameRightArrow}, + key.Filter{Focus: focusedID, Name: key.NameUpArrow}, + key.Filter{Focus: focusedID, Name: key.NameDownArrow}, + key.FocusFilter{Target: focusedID}, + ) if !ok { break } switch k := evt.(type) { case key.Event: + if k.Name == key.NameShift { + // Track shift; never forward it as a content key. + shiftDown = k.State == key.Press + continue + } if k.State == key.Press { + // ui.KeyEvent carries modifier state so handlers + // can distinguish shift+arrow (extend selection) + // from plain arrow (move cursor). On Android the + // Modifiers field is always empty, hence the shiftDown OR. events = append(events, ui.InputEvent{ Handler: reg.Handler, - Data: k.Name, + Data: ui.KeyEvent{ + Name: k.Name, + Shift: k.Modifiers.Contain(key.ModShift) || shiftDown, + }, }) } case key.EditEvent: diff --git a/doc/architecture.md b/doc/architecture.md index d9e1012..b5ce425 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -74,8 +74,22 @@ Key invariants: `frame.Scale`, sends `ScaleEvent` (after the draw). 2. Under the handoff lock: reads the `Frame` snapshot, calls `renderer.Draw(gtx, frame.Elems, scale)`, then `renderer.CheckGestures` - and the focused element's key/edit events (via `key.Filter` + - `key.FocusFilter`), then `e.Frame(&ops)`. + and the focused element's key/edit events, then `e.Frame(&ops)`. + Key events are queried with a catch-all `key.Filter{Focus: id}` **plus + one named filter per arrow key**. This is required on Android: the + window layer wraps plain arrow-key *presses* in `input.SystemEvent` + (it wants them for focus navigation), and system events match only + filters that name the key explicitly. Matching a named filter both makes + the press deliverable and suppresses the focus-move side effect (a + matched event makes `WakeupTime` report handled, skipping the window's + `moveFocus`). The **shift key is tracked here** (`shiftDown`): Gio's + Android JNI bridge never reads `KeyEvent.getMetaState`, so + `key.Event.Modifiers` is always 0 and shift+arrow is otherwise + indistinguishable from a plain arrow. `NameShift` press/release do + arrive as plain events; the tracked state is attached to the + `ui.KeyEvent{Shift: ...}` forwarded to the logic (OR-ed with the + Modifiers field so desktop behavior is unchanged). Shift state is + reset when no element is focused. 3. Outside the lock: sends `[]ui.InputEvent` (if any) to `InputChan`, the search text (if it differs from `frame.Query`) to `SearchQueryChan`, and `renderer.GlyphLayout()` to `LayoutChan`. @@ -188,11 +202,26 @@ Rules: chunk lengths. There is no lazy loading and no eviction (both existed as plans and were removed). - **Byte-indexed** throughout: `CursorPosition`, chunk offsets, and glyph - `ByteOffsets` are byte offsets. + `ByteOffsets` are byte offsets. All edit primitives are **rune-granular**: + `HandleBackspace`/`HandleDelete` compute the UTF-8 rune width at the cursor + (a byte-granular delete corrupts multi-byte characters, e.g. a two-byte + character straddling a chunk boundary); IME edits arrive as rune ranges; + tap-to-position lands on rune starts. - Edits splice the affected chunk(s) only; chunks are not rebalanced. - `LineIndex` (per-line byte offsets, `int32`) is built asynchronously by `BuildLineIndexTask` and stored on `cb.LineIndex` — the single source of - truth (the old parallel `EditorState.LineIndex` field was removed). + truth (the old parallel `EditorState.LineIndex` field was removed). A file + ending in `'\n'` has a trailing empty line (one offset past the last `\n`, + equal to the file length). +- **Incremental line-index maintenance.** Every buffer edit updates the index + in place instead of rebuilding it: `UpdateLineIndexAfterInsert(pos, text)` + (shifts starts above `pos` right, keeps a start at `pos`, adds one start per + inserted `\n`) and `UpdateLineIndexAfterDelete(start, end)` (drops starts in + `[start, end)`, shifts the rest left, re-inserts `start` iff it is a line + start in the new content). A start exactly at `end` always drops: it was + created by the `'\n'` at `end-1`, which the deletion removes (line merge). + These must be called in the same order as the buffer splice, and an IME + replace is `Delete` then `Insert` at the same position. - **Size guard:** files larger than `MaxEditableFileSize` (**50 MB**, measured on-device) open into a `TooLarge` state: the editor shows a notice and edit handlers are no-ops; the browser still lists the file. @@ -219,7 +248,29 @@ Only the visible byte range is shaped and drawn each frame: searching the offsets. For a whole-file window the base is 0 (a no-op). Getting this wrong snaps the cursor to the window top on scrolled large files. -### 6.3 IME (Android soft keyboard) +### 6.3 Selection and caret + +- Key presses arrive as `ui.KeyEvent{Name, Shift}` (main.go), not bare + `key.Name`, so handlers can distinguish shift+arrow from plain arrow. + On Android the `Shift` bit comes from main.go's own shift tracking, not + from Gio's Modifiers (see §2.1); arrow-key presses reach the handler only + because main.go registers the explicit named key filters. +- **Shift+arrow extends a selection**, plain arrow moves the caret and clears + it. `SelectionAnchor` is the fixed end, `CursorPosition` the active end; both + are **absolute file byte offsets**. No selection ⇔ `SelectionAnchor == -1`. + A zero-length shift selection keeps the anchor (`SelectionStart/End == -1`) + so the next shift-move extends from the original spot. +- Insert / backspace / delete with a live selection delete the whole selection + first (`deleteRange`), then insert; the selection is cleared afterwards. +- IME: with an active selection, `HandleReplaceRange` unions the IME-reported + range with the selection before splicing, so replacement is deterministic + whether the IME reports the caret (empty range) or the full range. +- Rendering: the `TextField` element carries **window-relative** selection + start/end into the visible `Value` (−1 = none) for the highlight, drawn + before the glyphs; the IME `SelectionCmd` push dedups on the + (selectionStart, caret) pair. + +### 6.4 IME (Android soft keyboard) - The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the visible viewport text, `IMEWindowStartByte` its absolute start. While the @@ -235,7 +286,7 @@ Only the visible byte range is shaped and drawn each frame: `deleteSurroundingText` (backspace/autocorrect replacement), and composition all arrive through this one path. -### 6.4 Autosave +### 6.5 Autosave - Any edit calls `markDirty()`: a 1 s debounce timer; each keystroke restarts it. On expiry the timer goroutine sends a token on `autosaveChan`; the owner @@ -245,7 +296,7 @@ Only the visible byte range is shaped and drawn each frame: retried via `retryChan`. There is no save button; autosave is the only persistence. -### 6.5 Opening a file +### 6.6 Opening a file - A browser tap sends the path on `OpenFileChan`. The logic goroutine creates the `ChunkedBuffer`, dispatches `StatFile` (size guard) and the read + @@ -312,6 +363,14 @@ Only the visible byte range is shaped and drawn each frame: (`development_plan.md` Phase 4). - Dead task types (`ReadChunk`, `SaveState`, `SaveUndo`, …) and unused element types are candidates for removal in the same round. +- The Android arrow-key/shift workaround in main.go (named `key.Filter`s + + app-side shift tracking, §2.1) compensates for two Gio v0.10 behaviors: the + JNI bridge dropping modifier state, and mobile arrow presses being wrapped + in `input.SystemEvent` for focus navigation. If Gio changes either + behavior (e.g. starts passing meta state), the shift tracking and the named + filters must be re-examined — the workaround would become redundant or + wrong. Re-verify on-device with `adb shell input keyevent 22` (cursor must + move) and `input keycombination 59 22` (selection must extend). ## 11. Performance profiler (default-off, `internal/perf`) diff --git a/doc/development_plan.md b/doc/development_plan.md index 369ed73..a7e2c7c 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -1,8 +1,8 @@ # Development Plan: reach a lean, usable Android text editor -Status: v6, 2026-08-16 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping -verification + tap-to-position-cursor fix). Written against the **live** repo -`/home/gmp/pad`. v1 (the widget-rebuild plan) is +Status: v7, 2026-08-16 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping +verification + tap-to-position-cursor fix + selection + real-file e2e). Written +against the **live** repo `/home/gmp/pad`. v1 (the widget-rebuild plan) is superseded — see §12 for why. Doc reorganization (2026-08-16): the over-detailed docs (`*_implementation_plan.md`, `touch.md`, `element_model.md`, `layout_rendering.md`, `virtual_scroll_render_optimization.md`, @@ -23,8 +23,27 @@ refactor); fixed with `tapLocalY`. (2) the `GlyphLayout` byte offsets are window-relative but the four cursor functions (tap, Home, End, vertical move) treated them as absolute, so the cursor snapped to the window top; fixed by adding the `IMEWindowStartByte` window base at each cursor boundary (`glyphBase`). Both -have regression tests. Remaining: real-device swipe/autocorrect sign-off (the -emulator's AOSP/Gboard keyboard is a proxy). +have regression tests. +Phase 8 (2026-08-16/17) added **text selection** (shift+arrow extend; insert/ +backspace/delete replace the selection; IME unions its range with the active +selection) with rendering + IME wiring. It also added **real-file e2e tests** +(`internal/test/e2e/real_file_*_test.go`: open/edit/autosave on real on-disk +files, incl. chunk-boundary and multi-byte edits) and found + fixed two real +bugs: (1) `UpdateLineIndexAfterEdit` only shifted offsets — any edit involving +newlines left the line index permanently inconsistent; replaced with +newline-aware `UpdateLineIndexAfterInsert`/`UpdateLineIndexAfterDelete`. +(2) `HandleBackspace`/`HandleDelete` deleted **one byte**, corrupting +multi-byte UTF-8 characters; now rune-granular. +On-device validation (2026-08-17) exposed a **third, platform-level gap**: +Gio v0.10 on Android (a) drops modifier state in the JNI bridge, and (b) +wraps plain arrow-key *presses* in `input.SystemEvent` for focus navigation, +so arrow keys never reached the editor and shift+arrow was impossible. +Fixed in main.go: explicit named `key.Filter`s for the four arrows (delivers +the press and suppresses the focus jump) plus app-side shift tracking. +Verified end-to-end on the emulator: plain arrows move the caret, +shift+arrow shows a highlight, typing replaces the selection. Remaining: +real-device swipe/autocorrect sign-off (the emulator's AOSP/Gboard keyboard +is a proxy). ## 1. Decision summary (updated) diff --git a/doc/spec.md b/doc/spec.md index d13d327..62b00ac 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -52,8 +52,15 @@ elsewhere. - **Virtualized viewport:** only the visible byte range is shaped and drawn each frame (typically ~4 KB of a large file), keeping frame cost and shaper memory constant regardless of file size. -- **Cursor + scroll:** tap to place the cursor, drag/scroll to pan, Home/End - and Page Up/Down on hardware keyboards, arrow keys. +- **Cursor + scroll:** tap to place the cursor, drag/scroll to pan; with a + hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor + (verified on the Android emulator; Gio's mobile focus-navigation default + for arrow keys is overridden — see architecture.md §2.1). +- **Text selection:** shift+arrow (hardware keyboard) extends a selection; + insert, backspace, and delete replace the selected text; the IME replaces + a selection when the user types over it. The selection is highlighted in + the editor and pushed to the IME. (Shift state is tracked by the app, + since Gio's Android bridge drops modifier keys — architecture.md §2.1.) - **Autosave:** every edit restarts a 1 s debounce; on expiry the full content is written to disk by a worker. Failed writes are retried. This is the only persistence mechanism. @@ -147,6 +154,7 @@ recorded here so future rounds don't mistake doc text for behavior: | File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. | | Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. | | In-file search, tabs, split view | **not implemented** | — | +| Touch selection (long-press, drag handles) | **not implemented** | Selection is reachable only via hardware-keyboard shift+arrow; touch users can position the caret by tap. | | Files > 50 MB | **not supported** | `TooLarge` state instead. | | Desktop / other platforms | **not supported** | Android-first. | diff --git a/internal/editor/chunked_buffer.go b/internal/editor/chunked_buffer.go index 44c01fd..522bc77 100644 --- a/internal/editor/chunked_buffer.go +++ b/internal/editor/chunked_buffer.go @@ -3,6 +3,7 @@ package editor import ( "bytes" "sort" + "strings" "pad/internal/io/pool" "pad/internal/io/pool/types" @@ -572,27 +573,85 @@ func (cb *ChunkedBuffer) visibleByteRangePrecise(scrollOffset ui.Dp, viewportHei return start, end } -// UpdateLineIndexAfterEdit updates the LineIndex offsets after an insert or -// delete. offsetShift is the number of bytes inserted (positive) or deleted -// (negative); editPos is the byte position where the edit occurred. -func (cb *ChunkedBuffer) UpdateLineIndexAfterEdit(editPos int, offsetShift int) { - if cb.LineIndex == nil { +// UpdateLineIndexAfterInsert records the insertion of `text` at absolute +// position `pos`, maintaining LineIndex incrementally: +// - old line starts below pos are unchanged; +// - an old line start exactly at pos stays at pos (the byte before it is +// unchanged by the insertion); +// - old line starts above pos shift right by len(text); +// - each '\n' inside `text` creates a new line start immediately after it. +// +// Equivalent to rebuilding the index from the edited content, but in +// O(lines affected) instead of O(file). +func (cb *ChunkedBuffer) UpdateLineIndexAfterInsert(pos int, text string) { + li := cb.LineIndex + if li == nil { return } - // Find the first offset that needs updating using binary search. All - // offsets >= editPos are shifted by offsetShift. - idx := sort.Search(len(cb.LineIndex.Offsets), func(i int) bool { - return int(cb.LineIndex.Offsets[i]) >= editPos - }) - if idx == 0 { - idx = 1 + old := li.Offsets + lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= pos }) + atPos := lower < len(old) && int(old[lower]) == pos + rest := lower + if atPos { + rest++ } - for i := idx; i < len(cb.LineIndex.Offsets); i++ { - cb.LineIndex.Offsets[i] += int32(offsetShift) + shift := int32(len(text)) + newOff := make([]int32, 0, len(old)+strings.Count(text, "\n")+1) + newOff = append(newOff, old[:lower]...) + if atPos { + newOff = append(newOff, int32(pos)) } - cb.LineIndex.Size += int64(offsetShift) - if cb.LineIndex.Size < 0 { - cb.LineIndex.Size = 0 + for i, b := range text { + if b == '\n' { + newOff = append(newOff, int32(pos+i+1)) + } + } + for _, o := range old[rest:] { + newOff = append(newOff, o+shift) + } + li.Offsets = newOff + li.Size += int64(len(text)) +} + +// UpdateLineIndexAfterDelete records the deletion of the absolute byte range +// [start, end), maintaining LineIndex incrementally: +// - old line starts below start are unchanged; +// - old line starts inside [start, end) are removed; +// - old line starts at or above end shift left by end-start, except the one +// at exactly end, which would land on `start` and is valid only if a line +// starts there in the new content; +// - `start` is (re)inserted as a line start iff start==0 or it was a line +// start in the pre-edit index (equivalently, the byte before it is '\n'; +// bytes below start are untouched by the deletion). +func (cb *ChunkedBuffer) UpdateLineIndexAfterDelete(start, end int) { + li := cb.LineIndex + if li == nil { + return + } + old := li.Offsets + shift := int32(end - start) + lower := sort.Search(len(old), func(i int) bool { return int(old[i]) >= start }) + atStart := lower < len(old) && int(old[lower]) == start + upper := sort.Search(len(old), func(i int) bool { return int(old[i]) >= end }) + newOff := make([]int32, 0, len(old)) + newOff = append(newOff, old[:lower]...) + if start == 0 || atStart { + newOff = append(newOff, int32(start)) + } + for _, o := range old[upper:] { + no := o - shift + if int(no) == start { + // The old line start at exactly `end` shifted onto `start`. A line + // starts there in the new content iff one already existed there + // (added above); in neither case do we keep this shifted entry. + continue + } + newOff = append(newOff, no) + } + li.Offsets = newOff + li.Size -= int64(end - start) + if li.Size < 0 { + li.Size = 0 } } diff --git a/internal/editor/chunked_buffer_test.go b/internal/editor/chunked_buffer_test.go index 28caf92..55bedc6 100644 --- a/internal/editor/chunked_buffer_test.go +++ b/internal/editor/chunked_buffer_test.go @@ -2,8 +2,11 @@ package editor import ( "bytes" + "fmt" "strings" "testing" + + "pad/internal/io/pool/types" ) // newTestBuffer builds a fully-loaded (in-range) buffer from content using the @@ -279,3 +282,176 @@ func TestInsert_SplitContentIntegrity(t *testing.T) { t.Fatalf("Content(50,300) mismatch across split boundary") } } + +// int32SlicesEqual reports whether two []int32 are element-wise equal. +func int32SlicesEqual(a, b []int32) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// lineStartOffsets computes the ground-truth line-start byte offsets for +// content: position 0 plus the byte after each '\n'. +func lineStartOffsets(content []byte) []int32 { + off := make([]int32, 0, 16) + off = append(off, 0) + for i, b := range content { + if b == '\n' { + off = append(off, int32(i+1)) + } + } + return off +} + +// assertLineIndexMatchesOracle verifies the buffer's incrementally-maintained +// LineIndex is exactly the one a full recomputation from the content would +// produce. This is the regression guard for newline handling in +// UpdateLineIndexAfterInsert / UpdateLineIndexAfterDelete. +func assertLineIndexMatchesOracle(t *testing.T, cb *ChunkedBuffer, label string) { + t.Helper() + full, err := cb.FullContent() + if err != nil { + t.Fatalf("%s: FullContent: %v", label, err) + } + want := lineStartOffsets([]byte(full)) + if cb.LineIndex == nil { + t.Fatalf("%s: LineIndex is nil", label) + } + if !int32SlicesEqual(cb.LineIndex.Offsets, want) { + t.Fatalf("%s: line index drift\n got %v\n want %v", label, cb.LineIndex.Offsets, want) + } + if cb.LineIndex.Size != int64(len(full)) { + t.Fatalf("%s: LineIndex.Size=%d, want %d", label, cb.LineIndex.Size, len(full)) + } +} + +// TestLineIndex_InsertNewlines drives inserts that add newlines through the +// incremental update and checks the oracle invariant after each step. +func TestLineIndex_InsertNewlines(t *testing.T) { + base := "aaa\nbbb\nccc" + cb := newTestBuffer(t, []byte(base)) + cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) + + steps := []struct { + pos int + text string + }{ + {7, "\n"}, // empty line after "bbb" + {0, "\n"}, // leading empty line + {len(base) + 2, "x\ny\n"}, // append two lines at EOF (after edits) + {4, "QQ\n"}, // insert at start of "bbb" line, with newline + {1000000, "zz"}, // insert far past EOF (clamps in Insert) + } + for i, s := range steps { + // Insert clamps pos to EOF; use the same clamped pos for both the + // buffer edit and the index update, as production code does (the + // cursor is always within the buffer). + p := s.pos + if p > int(cb.FileLen()) { + p = int(cb.FileLen()) + } + cb.Insert(p, s.text) + cb.UpdateLineIndexAfterInsert(p, s.text) + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("insert step %d (pos=%d text=%q)", i, p, s.text)) + } +} + +// TestLineIndex_DeleteNewlines drives deletions that remove newlines / whole +// lines / from line starts through the incremental update and checks the +// oracle invariant after each step. +func TestLineIndex_DeleteNewlines(t *testing.T) { + base := "aaa\nbbb\nccc\nddd" + cb := newTestBuffer(t, []byte(base)) + cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) + + // (start, end) ranges chosen to exercise: mid-line, across a newline, + // a whole line, from a line start, and the file start. + steps := []struct { + start, end int + }{ + {3, 4}, // delete the '\n' after aaa -> merge aaa+bbb + {4, 7}, // delete "bbb" (after merge: content is aaabbb\nddd...) + {0, 3}, // delete from file start + {3, 8}, // delete "bb\n" region + } + for i, s := range steps { + full, _ := cb.FullContent() + if s.start > len(full) || s.end > len(full) { + t.Fatalf("step %d: range [%d,%d) beyond content len %d", i, s.start, s.end, len(full)) + } + cb.Delete(s.start, s.end-s.start) + cb.UpdateLineIndexAfterDelete(s.start, s.end) + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("delete step %d [%d,%d)", i, s.start, s.end)) + } +} + +// TestLineIndex_MixedEditSequence interleaves inserts and deletes (including +// IME-style replace = delete+insert at the same point) and checks the oracle +// after each operation. +func TestLineIndex_MixedEditSequence(t *testing.T) { + base := "hello\nworld\nfoo\nbar\nbaz" + cb := newTestBuffer(t, []byte(base)) + cb.LineIndex = types.NewLineIndex(lineStartOffsets([]byte(base)), 0, int64(len(base))) + + ops := []struct { + desc string + isIns bool + pos int + text string // for insert + start int // for delete + end int // for delete + }{ + {desc: "insert newline mid", isIns: true, pos: 3, text: "\n"}, + {desc: "delete across nl", start: 6, end: 10}, + {desc: "replace with multi-line", isIns: false, pos: 2, text: "X\nY\nZ", start: 2, end: 4}, + {desc: "delete whole line", start: 0, end: 4}, + {desc: "insert at eof", isIns: true, pos: 999, text: "end\n"}, + } + for i, op := range ops { + full, _ := cb.FullContent() + switch { + case op.isIns: + p := op.pos + if p > len(full) { + p = len(full) + } + cb.Insert(p, op.text) + cb.UpdateLineIndexAfterInsert(p, op.text) + case op.text != "": + // replace: delete [start,end) then insert at start, mirroring + // HandleReplaceRange ordering. + s, e := op.start, op.end + if s > len(full) { + s = len(full) + } + if e > len(full) { + e = len(full) + } + if e > s { + cb.Delete(s, e-s) + cb.UpdateLineIndexAfterDelete(s, e) + } + cb.Insert(s, op.text) + cb.UpdateLineIndexAfterInsert(s, op.text) + default: + s, e := op.start, op.end + if s > len(full) { + s = len(full) + } + if e > len(full) { + e = len(full) + } + if e > s { + cb.Delete(s, e-s) + cb.UpdateLineIndexAfterDelete(s, e) + } + } + assertLineIndexMatchesOracle(t, cb, fmt.Sprintf("op %d (%s)", i, op.desc)) + } +} diff --git a/internal/editor/selection_test.go b/internal/editor/selection_test.go new file mode 100644 index 0000000..8ec830e --- /dev/null +++ b/internal/editor/selection_test.go @@ -0,0 +1,347 @@ +package editor + +import ( + "testing" + + "gioui.org/io/key" + + "pad/internal/ui" +) + +// selState sets up a fresh state with a string buffer (the selection logic +// is buffer-implementation independent; chunked-buffer coverage is in the +// e2e real-file tests). +func selState(content string) { + TheState = NewState() + TheState.Editor.Buffer = content + TheState.Editor.CursorPosition = len(content) +} + +// assertSelection checks the normalized selection triple. +func assertSelection(t *testing.T, wantStart, wantEnd int) { + t.Helper() + e := TheState.Editor + if e.SelectionStart != wantStart || e.SelectionEnd != wantEnd { + t.Fatalf("selection = [%d,%d), want [%d,%d)", e.SelectionStart, e.SelectionEnd, wantStart, wantEnd) + } + active := wantStart >= 0 && wantEnd > wantStart + if active != selActive() { + t.Fatalf("selActive() = %v, want %v (anchor=%d)", selActive(), active, e.SelectionAnchor) + } +} + +func TestSetSelection_Basic(t *testing.T) { + selState("0123456789") + SetSelection(2, 7) + assertSelection(t, 2, 7) + if TheState.Editor.SelectionAnchor != 2 { + t.Errorf("anchor = %d, want 2", TheState.Editor.SelectionAnchor) + } + if TheState.Editor.CursorPosition != 7 { + t.Errorf("cursor = %d, want 7 (active end)", TheState.Editor.CursorPosition) + } + + // Reversed arguments are normalized. + SetSelection(9, 4) + assertSelection(t, 4, 9) + if TheState.Editor.SelectionAnchor != 4 { + t.Errorf("anchor = %d, want 4", TheState.Editor.SelectionAnchor) + } +} + +func TestSetSelection_Clamp(t *testing.T) { + selState("0123456789") + // End past EOF clamps to file length. + SetSelection(7, 100) + assertSelection(t, 7, 10) + + // Negative start clamps to 0. + SetSelection(-5, 3) + assertSelection(t, 0, 3) + + // Degenerate range clears instead of selecting. + SetSelection(5, 5) + assertSelection(t, -1, -1) + if TheState.Editor.CursorPosition != 5 { + t.Errorf("cursor = %d, want 5", TheState.Editor.CursorPosition) + } +} + +func TestShiftExtend_Horizontal(t *testing.T) { + selState("0123456789") + TheState.Editor.CursorPosition = 3 + + // First shift-move anchors at the cursor and selects one char. + handleKey(key.NameRightArrow, true) + assertSelection(t, 3, 4) + + // Further shift-moves extend from the anchor. + handleKey(key.NameRightArrow, true) + assertSelection(t, 3, 5) + handleKey(key.NameRightArrow, true) + assertSelection(t, 3, 6) + + // Left moves shrink back toward the anchor; crossing it keeps the + // selection (anchor is the fixed end). + handleKey(key.NameLeftArrow, true) + assertSelection(t, 3, 5) + handleKey(key.NameLeftArrow, true) + assertSelection(t, 3, 4) + // Crossing the anchor: zero-length, represented as no selection. + handleKey(key.NameLeftArrow, true) + assertSelection(t, -1, -1) + // ...but the anchor survives so the next shift-move extends from the + // original spot. + if TheState.Editor.SelectionAnchor != 3 { + t.Errorf("anchor = %d, want 3 (kept across zero-length)", TheState.Editor.SelectionAnchor) + } + handleKey(key.NameLeftArrow, true) + assertSelection(t, 2, 3) + + // A plain move clears the selection and moves one step (cursor 2 -> 1). + handleKey(key.NameLeftArrow, false) + assertSelection(t, -1, -1) + if TheState.Editor.SelectionAnchor != -1 { + t.Errorf("anchor = %d, want -1 after plain move", TheState.Editor.SelectionAnchor) + } + if TheState.Editor.CursorPosition != 1 { + t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition) + } +} + +func TestShiftExtend_UpDownHomeEnd(t *testing.T) { + TheState = NewState() + TheState.Editor.Buffer = "hello\nworld" + TheState.Editor.CursorPosition = 8 // line 1, inside "world" + TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld") + + // Layout: line 0 "hello" baseline Y=70, line 1 "world" baseline Y=140; + // X = 10 + 10*(column). Cursor 8 is line 1, column 2 (X=30). + handleKey(key.NameUpArrow, true) + // Up goes to the closest X on line 0: X=30 is byte 2 exactly. + assertSelection(t, 2, 8) + if TheState.Editor.CursorPosition != 2 { + t.Fatalf("cursor = %d, want 2", TheState.Editor.CursorPosition) + } + + // Shift+End moves to end of line 0 (byte 5); the anchor stays at 8. + handleKey(key.NameEnd, true) + assertSelection(t, 5, 8) + + // Plain Down clears and moves to the closest X on line 1. Cursor is at + // line 0 col 4 (X=50) -> line 1 col 4 = byte 10. + handleKey(key.NameDownArrow, false) + assertSelection(t, -1, -1) + if TheState.Editor.CursorPosition != 10 { + t.Fatalf("cursor = %d, want 10", TheState.Editor.CursorPosition) + } + + // Shift+End extends to end of file (byte 11), anchoring at 10. + handleKey(key.NameEnd, true) + assertSelection(t, 10, 11) + + // Shift+Home selects back to line start (byte 6), anchor 10 kept. + handleKey(key.NameHome, true) + assertSelection(t, 6, 10) + + // Plain Home clears and moves. + handleKey(key.NameHome, false) + assertSelection(t, -1, -1) + if TheState.Editor.CursorPosition != 6 { + t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition) + } +} + +func TestInsert_ReplacesSelection(t *testing.T) { + selState("hello world") + SetSelection(5, 11) // " world" + HandleInsert("GO") + if TheState.Editor.Buffer != "helloGO" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "helloGO") + } + if TheState.Editor.CursorPosition != 7 { + t.Errorf("cursor = %d, want 7", TheState.Editor.CursorPosition) + } + assertSelection(t, -1, -1) +} + +func TestBackspace_DeletesSelection(t *testing.T) { + selState("0123456789") + SetSelection(2, 7) + HandleBackspace() + if TheState.Editor.Buffer != "01789" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789") + } + if TheState.Editor.CursorPosition != 2 { + t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) + } + assertSelection(t, -1, -1) +} + +func TestDeleteForward_DeletesSelection(t *testing.T) { + selState("0123456789") + SetSelection(2, 7) + HandleDelete() + if TheState.Editor.Buffer != "01789" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "01789") + } + if TheState.Editor.CursorPosition != 2 { + t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) + } + assertSelection(t, -1, -1) +} + +func TestBackspace_NoSelection_StillDeletesChar(t *testing.T) { + selState("0123456789") + TheState.Editor.CursorPosition = 5 + HandleBackspace() + // Deletes the char before the cursor (index 4, '4'). + if TheState.Editor.Buffer != "012356789" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "012356789") + } + if TheState.Editor.CursorPosition != 4 { + t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) + } +} + +func TestHandleReplaceRange_UnionsSelection(t *testing.T) { + selState("hello world") + SetSelection(0, 5) // "hello", cursor at 5 + + // IME reports an empty range at the caret: the selection must still be + // consumed. + HandleReplaceRange(5, 5, "X") + if TheState.Editor.Buffer != "X world" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world") + } + if TheState.Editor.CursorPosition != 1 { + t.Errorf("cursor = %d, want 1", TheState.Editor.CursorPosition) + } + assertSelection(t, -1, -1) + + // IME reports the full selection range: same outcome (idempotent union). + selState("hello world") + SetSelection(0, 5) + HandleReplaceRange(0, 5, "X") + if TheState.Editor.Buffer != "X world" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "X world") + } + + // No selection: ordinary insert at the caret (regression guard). + selState("hello") + TheState.Editor.CursorPosition = 5 + HandleReplaceRange(5, 5, "!") + if TheState.Editor.Buffer != "hello!" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!") + } + if TheState.Editor.CursorPosition != 6 { + t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition) + } +} + +func TestHandleReplaceRange_SelectionPartialOverlap(t *testing.T) { + // Selection [2,7); IME commit inserts at caret 7 (just outside the + // selection): union [2,7) -> the selected text is replaced, inserted + // text ends up where the selection was. + selState("0123456789") + SetSelection(2, 7) + HandleReplaceRange(7, 7, "AB") + want := "01AB789" + if TheState.Editor.Buffer != want { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want) + } + if TheState.Editor.CursorPosition != 4 { + t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) + } + assertSelection(t, -1, -1) +} + +func TestSelectionEdit_UTF8(t *testing.T) { + // "hélló": é is 2 bytes (1..3), ó is 2 bytes (5..7). Select the + // multi-byte span [1,5) = "éll" and replace it: must not split runes. + selState("hélló") + SetSelection(1, 5) + HandleInsert("a") + want := "ha" + "ó" + if TheState.Editor.Buffer != want { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, want) + } + if TheState.Editor.CursorPosition != 2 { + t.Errorf("cursor = %d, want 2", TheState.Editor.CursorPosition) + } +} + +func TestSetCursorFromPoint_ClearsSelection(t *testing.T) { + TheState = NewState() + TheState.Editor.Buffer = "hello\nworld" + TheState.Editor.GlyphLayout = makeTestLineLayout("hello\nworld") + SetSelection(0, 5) + assertSelection(t, 0, 5) + + // Tap on the second line (Y of line 1 glyphs). + SetCursorFromPoint(25, secondLineY(t)) + assertSelection(t, -1, -1) + if TheState.Editor.CursorPosition < 6 || TheState.Editor.CursorPosition > 11 { + t.Errorf("cursor = %d, want in [6,11] (line 1)", TheState.Editor.CursorPosition) + } +} + +func TestBareKeyName_ShiftIgnored(t *testing.T) { + // The legacy bare key.Name path has no modifier state: it must never + // start a selection. + selState("0123456789") + TheState.Editor.CursorPosition = 3 + HandleKeyDown(key.NameRightArrow) + assertSelection(t, -1, -1) + if TheState.Editor.CursorPosition != 4 { + t.Errorf("cursor = %d, want 4", TheState.Editor.CursorPosition) + } +} + +func TestKeyEvent_DoesNotAffectIMEEvents(t *testing.T) { + // key.EditEvent still flows to HandleReplaceRange unchanged. + selState("hello") + TheState.Editor.CursorPosition = 5 + HandleKeyDown(key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "!"}) + if TheState.Editor.Buffer != "hello!" { + t.Fatalf("buffer = %q, want %q", TheState.Editor.Buffer, "hello!") + } +} + +// makeTestLineLayout builds a GlyphLayout for an ASCII buffer whose lines +// are laid out at fixed baselines (line i at Y=70+70*i), one glyph per byte +// with X = 10 + 10*column and advance 10. +func makeTestLineLayout(content string) ui.GlyphLayout { + var layout ui.GlyphLayout + y := 70 + col := 0 + for i := 0; i < len(content); i++ { + if i > 0 && content[i-1] == '\n' { + y += 70 + col = 0 + } + layout.ByteOffsets = append(layout.ByteOffsets, i) + layout.X = append(layout.X, 10+ui.Dp(col)*10) + layout.Y = append(layout.Y, ui.Dp(y)) + layout.Advance = append(layout.Advance, 10) + if content[i] != '\n' { + col++ + } + } + layout.LineHeight = 20 + return layout +} + +// secondLineY returns the Y of the first glyph of the second line in the +// layout produced by makeTestLineLayout. +func secondLineY(t *testing.T) float64 { + t.Helper() + l := TheState.Editor.GlyphLayout + for i := 1; i < len(l.Y); i++ { + if l.Y[i] > l.Y[0] { + return float64(l.Y[i]) + } + } + t.Fatal("layout has no second line") + return 0 +} diff --git a/internal/editor/state.go b/internal/editor/state.go index 74fe999..9236596 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -64,7 +64,12 @@ type EditorState struct { GlyphLayout ui.GlyphLayout SelectionStart int SelectionEnd int - CursorVisible bool + // SelectionAnchor is the fixed end of a shift-selection; CursorPosition is + // the active end. -1 means no shift-selection in progress. The effective + // selection is [min(anchor,cursor), max(anchor,cursor)]; SelectionStart/ + // SelectionEnd are its cached normalized form (-1/-1 = none). + SelectionAnchor 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 @@ -169,6 +174,7 @@ func NewState() *State { CursorPosition: 0, SelectionStart: -1, SelectionEnd: -1, + SelectionAnchor: -1, fileVersion: make(map[string]int), lastWriteVersion: make(map[string]int), writeFailed: make(map[string]bool), @@ -345,7 +351,8 @@ func HandleCursorMove(delta int) { } // HandleKeyDown interprets keyboard events for navigation and editing. -// Receives both key.Event (as key.Name) and key.EditEvent from the main loop. +// Receives key.EditEvent (text input) and key presses as ui.KeyEvent (with +// modifier state) or bare key.Name (legacy/test path, no modifiers). func HandleKeyDown(data any) { // A too-large file is not editable: ignore all key input. if TheState.Editor.TooLarge { @@ -355,42 +362,52 @@ func HandleKeyDown(data any) { case key.EditEvent: // Text input from IME / keyboard. if v.Text == "\b" { - // Backspace character (legacy / hardware): delete one char before - // the cursor. + // Backspace character (legacy / hardware): delete the selection if + // there is one, else one char before the cursor. HandleBackspace() } else { // IME insert/replace/delete: replace [Range.Start, Range.End) with // Text. Range is empty (start==end) for plain inserts; a non-empty // range with text is a swipe/autocorrect replacement; a non-empty // range with empty text is a range delete. Ignoring Range here is - // what caused replaced text to be duplicated. + // what caused replaced text to be duplicated. A live selection is + // always replaced (see HandleReplaceRange). HandleReplaceRange(v.Range.Start, v.Range.End, v.Text) } + case ui.KeyEvent: + handleKey(v.Name, v.Shift) case key.Name: - switch v { - case key.NameLeftArrow: - HandleCursorMove(-1) - case key.NameRightArrow: - HandleCursorMove(1) - case key.NameUpArrow: - HandleVerticalCursorMove(true) - case key.NameDownArrow: - HandleVerticalCursorMove(false) - case key.NameDeleteBackward: - HandleBackspace() - case key.NameDeleteForward: - HandleDelete() - case key.NameReturn: - HandleInsert("\n") - case key.NameHome: - HandleHome() - case key.NameEnd: - HandleEnd() - case key.NamePageUp: - HandlePageUpDown(true) - case key.NamePageDown: - HandlePageUpDown(false) - } + // Bare key name: no modifier state, so no shift-selection here. + handleKey(v, false) + } +} + +// handleKey dispatches a key press. shift=true turns cursor moves into +// selection extensions; edit keys act on the selection when one is active. +func handleKey(name key.Name, shift bool) { + switch name { + case key.NameLeftArrow: + moveCursor(shift, func() { HandleCursorMove(-1) }) + case key.NameRightArrow: + moveCursor(shift, func() { HandleCursorMove(1) }) + case key.NameUpArrow: + moveCursor(shift, func() { HandleVerticalCursorMove(true) }) + case key.NameDownArrow: + moveCursor(shift, func() { HandleVerticalCursorMove(false) }) + case key.NameDeleteBackward: + HandleBackspace() + case key.NameDeleteForward: + HandleDelete() + case key.NameReturn: + HandleInsert("\n") + case key.NameHome: + moveCursor(shift, HandleHome) + case key.NameEnd: + moveCursor(shift, HandleEnd) + case key.NamePageUp: + moveCursor(shift, func() { HandlePageUpDown(true) }) + case key.NamePageDown: + moveCursor(shift, func() { HandlePageUpDown(false) }) } } @@ -403,6 +420,117 @@ func glyphBase() int { return TheState.Editor.IMEWindowStartByte } +// --- Text selection --------------------------------------------------------- +// +// The selection is a byte range in absolute file coordinates derived from +// SelectionAnchor (fixed end) and CursorPosition (active end). No selection: +// anchor == -1 and SelectionStart == SelectionEnd == -1. Shift+arrow/home/ +// end/page extend the selection; a plain cursor move or any edit clears it. +// All of these must run on the logic goroutine (owner). + +// selActive reports whether there is a non-empty selection. +func selActive() bool { + e := &TheState.Editor + return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart +} + +// ClearSelection drops the selection and the shift-anchor. +func ClearSelection() { + e := &TheState.Editor + e.SelectionAnchor = -1 + e.SelectionStart = -1 + e.SelectionEnd = -1 +} + +// SetSelection selects the byte range [min(start,end), max(start,end)), +// anchoring at the lower end and placing the cursor (active end) at the upper +// end. Clamped to the buffer; an empty/clamped range clears instead. +// Convenience for tests and future gestures (tap-range, double-tap). +func SetSelection(start, end int) { + e := &TheState.Editor + if start > end { + start, end = end, start + } + if start < 0 { + start = 0 + } + var fileLen int + if cb := e.ChunkedBuffer; cb != nil { + fileLen = int(cb.FileLen()) + } else { + fileLen = len(e.Buffer) + } + if end > fileLen { + end = fileLen + } + if end <= start { + ClearSelection() + e.CursorPosition = start + return + } + e.SelectionAnchor = start + e.SelectionStart = start + e.SelectionEnd = end + e.CursorPosition = end +} + +// updateSelectionFromAnchor recomputes SelectionStart/End from (anchor, +// cursor). A zero-length range (anchor == cursor) is treated as no selection, +// but the anchor is kept so the next shift-move extends from the original spot +// again. +func updateSelectionFromAnchor() { + e := &TheState.Editor + if e.SelectionAnchor < 0 { + ClearSelection() + return + } + a, c := e.SelectionAnchor, e.CursorPosition + if a == c { + e.SelectionStart = -1 + e.SelectionEnd = -1 + return + } + if a < c { + e.SelectionStart, e.SelectionEnd = a, c + } else { + e.SelectionStart, e.SelectionEnd = c, a + } +} + +// moveCursor runs op (a cursor-mutating handler). With shift held it keeps +// the anchor and extends the selection to the new cursor position; without it +// it clears any selection first. This is the single place where selection +// bookkeeping meets cursor movement, so every move path (arrows, home/end, +// page, vertical) gets consistent semantics. +func moveCursor(shift bool, op func()) { + if shift { + if TheState.Editor.SelectionAnchor < 0 { + TheState.Editor.SelectionAnchor = TheState.Editor.CursorPosition + } + } else { + ClearSelection() + } + op() + if shift { + updateSelectionFromAnchor() + } +} + +// deleteRange removes the byte range [start, end) from the active buffer and +// updates the line index. Shared by the selection-aware edit handlers. +func deleteRange(start, end int) { + if end <= start { + return + } + if buf := TheState.Editor.ChunkedBuffer; buf != nil { + buf.Delete(start, end-start) + buf.UpdateLineIndexAfterDelete(start, end) + } else { + str := TheState.Editor.Buffer + TheState.Editor.Buffer = str[:start] + str[end:] + } +} + // HandleHome moves the cursor to the start of the current visual line. func HandleHome() { layout := TheState.Editor.GlyphLayout @@ -599,56 +727,130 @@ func HandleVerticalCursorMove(up bool) { TheState.Editor.CursorPosition = base + layout.ByteOffsets[targetIdx] } -// HandleDelete removes the character after the cursor. -func HandleDelete() { - pos := TheState.Editor.CursorPosition - buf := TheState.Editor.ChunkedBuffer - if buf != nil { - buf.Delete(pos, 1) - buf.UpdateLineIndexAfterEdit(pos, -1) - } else { - // Fallback to string-based editing for small files / no chunked buffer - str := TheState.Editor.Buffer - if pos >= len(str) { - return - } - TheState.Editor.Buffer = str[:pos] + str[pos+1:] +// utf8BackspaceWidth returns the byte width of the UTF-8 rune ending at the +// end of seg (the rune immediately before the cursor). seg must contain the +// rune's full bytes (4 or more preceding bytes suffice). +func utf8BackspaceWidth(seg string) int { + // The rune ends at the end of seg. Scan back over continuation bytes + // (0x80-0xBF) until the lead byte; the width is the span covered. + i := len(seg) - 1 + for i > 0 && seg[i]&0xC0 == 0x80 { + i-- } + return len(seg) - i +} + +// utf8AdvanceWidth returns the byte width of the UTF-8 rune starting at the +// beginning of seg (4 or more following bytes suffice). Returns 0 if seg is +// empty (cursor at EOF). +func utf8AdvanceWidth(seg string) int { + if len(seg) == 0 { + return 0 + } + w := 1 + for i := 1; i < len(seg) && seg[i]&0xC0 == 0x80; i++ { + w++ + } + return w +} + +// HandleDelete removes the character (full UTF-8 rune) after the cursor. +// With a live selection it deletes the whole selection. +func HandleDelete() { + e := &TheState.Editor + if selActive() { + deleteRange(e.SelectionStart, e.SelectionEnd) + e.CursorPosition = e.SelectionStart + ClearSelection() + markDirty() + return + } + pos := e.CursorPosition + buf := e.ChunkedBuffer + if buf != nil { + seg := buf.Content(pos, pos+4) + if w := utf8AdvanceWidth(seg); w > 0 { + buf.Delete(pos, w) + buf.UpdateLineIndexAfterDelete(pos, pos+w) + markDirty() + } + return + } + // Fallback to string-based editing for small files / no chunked buffer + str := TheState.Editor.Buffer + if pos >= len(str) { + return + } + end := len(str) + if pos+4 < end { + end = pos + 4 + } + TheState.Editor.Buffer = str[:pos] + str[pos+utf8AdvanceWidth(str[pos:end]):] markDirty() } -// HandleInsert inserts a string at the current cursor position. +// HandleInsert inserts a string at the current cursor position. With a live +// selection it replaces the selection instead. func HandleInsert(s string) { - pos := TheState.Editor.CursorPosition - buf := TheState.Editor.ChunkedBuffer + // EditorState is embedded by value in State: take the address, never a + // copy, or the writes below are lost. + e := &TheState.Editor + pos := e.CursorPosition + if selActive() { + pos = e.SelectionStart + deleteRange(e.SelectionStart, e.SelectionEnd) + ClearSelection() + } + buf := e.ChunkedBuffer if buf != nil { buf.Insert(pos, s) - buf.UpdateLineIndexAfterEdit(pos, len(s)) + buf.UpdateLineIndexAfterInsert(pos, s) } else { // Fallback to string-based editing for small files / no chunked buffer - str := TheState.Editor.Buffer - TheState.Editor.Buffer = str[:pos] + s + str[pos:] + str := e.Buffer + e.Buffer = str[:pos] + s + str[pos:] } - TheState.Editor.CursorPosition += len(s) + e.CursorPosition = pos + len(s) markDirty() } -// HandleBackspace removes the character before the cursor. +// HandleBackspace removes the character before the cursor. With a live +// selection it deletes the whole selection instead. func HandleBackspace() { - pos := TheState.Editor.CursorPosition + e := &TheState.Editor + if selActive() { + deleteRange(e.SelectionStart, e.SelectionEnd) + e.CursorPosition = e.SelectionStart + ClearSelection() + markDirty() + return + } + pos := e.CursorPosition if pos == 0 { return } - buf := TheState.Editor.ChunkedBuffer + buf := e.ChunkedBuffer if buf != nil { - buf.Delete(pos-1, 1) - buf.UpdateLineIndexAfterEdit(pos-1, -1) - } else { - // Fallback to string-based editing for small files / no chunked buffer - str := TheState.Editor.Buffer - TheState.Editor.Buffer = str[:pos-1] + str[pos:] + segStart := pos - 4 + if segStart < 0 { + segStart = 0 + } + w := utf8BackspaceWidth(buf.Content(segStart, pos)) + buf.Delete(pos-w, w) + buf.UpdateLineIndexAfterDelete(pos-w, pos) + TheState.Editor.CursorPosition = pos - w + markDirty() + return } - TheState.Editor.CursorPosition-- + // Fallback to string-based editing for small files / no chunked buffer + str := TheState.Editor.Buffer + segStart := pos - 4 + if segStart < 0 { + segStart = 0 + } + w := utf8BackspaceWidth(str[segStart:pos]) + TheState.Editor.Buffer = str[:pos-w] + str[pos:] + TheState.Editor.CursorPosition = pos - w markDirty() } @@ -710,6 +912,19 @@ func HandleReplaceRange(startRune, endRune int, text string) { } absStart := windowStart + runeIndexToByteStr(windowText, startRune) absEnd := windowStart + runeIndexToByteStr(windowText, endRune) + // 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 absEnd < TheState.Editor.SelectionEnd { + absEnd = 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) @@ -721,7 +936,10 @@ func HandleReplaceRange(startRune, endRune int, text string) { buf.Delete(absStart, absEnd-absStart) } buf.Insert(absStart, text) - buf.UpdateLineIndexAfterEdit(absStart, len(text)-(absEnd-absStart)) + if absEnd > absStart { + buf.UpdateLineIndexAfterDelete(absStart, absEnd) + } + buf.UpdateLineIndexAfterInsert(absStart, text) newCursor = absStart + len(text) } else { s := TheState.Editor.Buffer @@ -732,6 +950,8 @@ func HandleReplaceRange(startRune, endRune int, text string) { newCursor = absStart + 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 { @@ -965,6 +1185,24 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { // window is the whole buffer there. TheState.Editor.IMEWindowStartByte = start TheState.Editor.IMEWindowText = visibleContent + // 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 + // offsets in EditorState. + windowSelStart, windowSelEnd := -1, -1 + if ss := TheState.Editor.SelectionStart; ss >= 0 && TheState.Editor.SelectionEnd > ss { + ws, we := ss-start, TheState.Editor.SelectionEnd-start + if ws < 0 { + ws = 0 + } + if we > len(visibleContent) { + we = len(visibleContent) + } + if ws < we { + windowSelStart, windowSelEnd = ws, we + } + } + // Add the TextField back in a way that passes the test. editorElem := ui.NewTextField( "editor_text", @@ -973,6 +1211,8 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { editorRegion.W, visibleScrollOffset, visibleCursorPos, + windowSelStart, + windowSelEnd, []ui.Interaction{ {Gesture: ui.Scroll, Handler: HandleScroll}, {Gesture: ui.KeyDown, Handler: HandleKeyDown}, @@ -1016,6 +1256,8 @@ func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 { // SetCursorFromPoint updates the cursor position based on screen coordinates (Dp). func SetCursorFromPoint(x, y float64) { + // A tap is an explicit cursor placement: it always clears any selection. + ClearSelection() layout := TheState.Editor.GlyphLayout if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 { return diff --git a/internal/test/e2e/harness.go b/internal/test/e2e/harness.go index 682e033..c7640e3 100644 --- a/internal/test/e2e/harness.go +++ b/internal/test/e2e/harness.go @@ -6,6 +6,7 @@ import ( "time" "pad/internal/editor" + "pad/internal/io/pool" "pad/internal/ui" ) @@ -19,23 +20,38 @@ type Harness struct { frameReceiverDone chan struct{} wg sync.WaitGroup started bool + fs pool.FileSystem // nil = mock filesystem (default) + startPath string // browser root (default "/") } // HarnessOption configures the test harness. type HarnessOption func(*Harness) -// NewHarness creates a test harness with the given options. +// WithFileSystem uses the given filesystem instead of the mock and startPath +// as the browser root. Pass real.NewRealFileSystem(t.TempDir()) to run the +// full open/edit/autosave cycle against real on-disk files. +func WithFileSystem(fs pool.FileSystem, startPath string) HarnessOption { + return func(h *Harness) { + h.fs = fs + h.startPath = startPath + } +} + +// NewHarness creates a test harness with the given options. The logic +// instance is created here (after options are applied) so WithFileSystem can +// substitute the filesystem; the no-op openfunc matches impl_other.go. func NewHarness(opts ...HarnessOption) *Harness { h := &Harness{ - logic: editor.NewLogic(nil, "/", func(string) {}), // no-op openfunc (matches impl_other.go) capture: NewFrameCapture(), frameReceiverDone: make(chan struct{}), + startPath: "/", } for _, opt := range opts { opt(h) } + h.logic = editor.NewLogic(h.fs, h.startPath, func(string) {}) return h } @@ -123,6 +139,16 @@ func (h *Harness) WithState(fn func(st *editor.State)) error { return err } +// Logic exposes the underlying editor.Logic for owner-side operations that +// have no State-level equivalent (e.g. FlushAll). +func (h *Harness) Logic() *editor.Logic { return h.logic } + +// Flush forces an immediate autosave flush (owner-side), so tests can verify +// on-disk content without waiting for the 1 s autosave debounce. +func (h *Harness) Flush() error { + return h.WithState(func(st *editor.State) { h.logic.FlushAll() }) +} + // FileLoaded reports whether the active file's chunked buffer has loaded // content (owner-side check). func (h *Harness) FileLoaded() (bool, error) { diff --git a/internal/test/e2e/real_file_chunk_boundary_test.go b/internal/test/e2e/real_file_chunk_boundary_test.go new file mode 100644 index 0000000..7d21cab --- /dev/null +++ b/internal/test/e2e/real_file_chunk_boundary_test.go @@ -0,0 +1,224 @@ +package e2e_test + +import ( + "fmt" + "strings" + "testing" + + "pad/internal/editor" +) + +// lineFile builds a file of nLines lines, each exactly 512 bytes: +// "LINE%06d:" (11) + 500 'x' (500) + "\n" (1). Line i therefore starts at +// byte i*512, so with the default 64 KiB chunk size, lines 128, 256, 384, ... +// start exactly on chunk boundaries. The file ends with '\n', so the line +// index has nLines+1 entries (a trailing empty line). +func lineFile(nLines int) string { + var sb strings.Builder + for i := 0; i < nLines; i++ { + fmt.Fprintf(&sb, "LINE%06d:", i) + sb.WriteString(strings.Repeat("x", 500)) + sb.WriteByte('\n') + } + return sb.String() +} + +const ( + chunkBytes = 64 * 1024 + lineBytes = 512 +) + +// TestRealFile_ChunkBoundary_InsertAtBoundary inserts at a line that starts +// exactly on a chunk boundary and verifies the buffer and disk agree. +func TestRealFile_ChunkBoundary_InsertAtBoundary(t *testing.T) { + content := lineFile(500) // 256 KiB, 4 chunks + h, path := realFileHarness(t, "boundary.txt", content) + defer h.Cleanup() + + pos := 128 * lineBytes // start of LINE000128 == byte 65536 == chunk edge + if pos != chunkBytes { + t.Fatalf("test premise broken: %d != %d", pos, chunkBytes) + } + original, _ := h.FullContent() + + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + editor.HandleInsert("INSERTED-LINE\n") + }); err != nil { + t.Fatalf("insert: %v", err) + } + want := original[:pos] + "INSERTED-LINE\n" + original[pos:] + got, _ := h.FullContent() + if got != want { + t.Fatalf("buffer mismatch after boundary insert (len %d vs %d)", len(got), len(want)) + } + // 500 lines + trailing empty line + 1 new line = 502 index entries. + checkLineIndex(t, h, want, 502) + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != want { + t.Fatalf("disk mismatch after boundary insert (len %d vs %d)", len(disk), len(want)) + } +} + +// TestRealFile_ChunkBoundary_DeleteAcrossBoundary deletes a range that spans +// a chunk boundary (via selection) and verifies consistency. +func TestRealFile_ChunkBoundary_DeleteAcrossBoundary(t *testing.T) { + content := lineFile(500) + h, path := realFileHarness(t, "boundary.txt", content) + defer h.Cleanup() + + pos := 256 * lineBytes // second chunk edge (byte 131072) + lo, hi := pos-10, pos+10 + original, _ := h.FullContent() + + if err := h.WithState(func(st *editor.State) { + editor.SetSelection(lo, hi) + editor.HandleBackspace() + }); err != nil { + t.Fatalf("delete: %v", err) + } + want := original[:lo] + original[hi:] + got, _ := h.FullContent() + if got != want { + t.Fatalf("buffer mismatch after cross-boundary delete (len %d vs %d)", len(got), len(want)) + } + // The range includes the '\n' ending line 255 (byte pos-1), so lines 255 + // and 256 merge into one: 501 entries -> 500. + checkLineIndex(t, h, want, 500) + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != want { + t.Fatalf("disk mismatch after cross-boundary delete") + } +} + +// TestRealFile_ChunkBoundary_SustainedTypingAtBoundary inserts a large string +// at a chunk edge, forcing chunk splits (per-edit copy bound) and line-index +// shifts, then verifies the result byte-for-byte. +func TestRealFile_ChunkBoundary_SustainedTypingAtBoundary(t *testing.T) { + content := lineFile(500) + h, path := realFileHarness(t, "boundary.txt", content) + defer h.Cleanup() + + pos := 384 * lineBytes // third chunk edge + original, _ := h.FullContent() + + // 3000 chars with a single trailing newline: inserts one new line at the + // top of LINE000384 and pushes everything after it down. + pasted := strings.Repeat("p", 2990) + "PASTE-END\n" // len 3000 + if len(pasted) != 3000 { + t.Fatalf("pasted len = %d", len(pasted)) + } + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = pos + editor.HandleInsert(pasted) + }); err != nil { + t.Fatalf("paste: %v", err) + } + want := original[:pos] + pasted + original[pos:] + got, _ := h.FullContent() + if got != want { + t.Fatalf("buffer mismatch after sustained typing at boundary (len %d vs %d)", len(got), len(want)) + } + // 501 original entries (incl. trailing empty line) + 1 new line. + checkLineIndex(t, h, want, 502) + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != want { + t.Fatalf("disk mismatch after sustained typing at boundary") + } +} + +// TestRealFile_ChunkBoundary_UTF8SplitAcrossBoundary verifies a file whose +// multi-byte rune straddles a chunk boundary loads intact and edits cleanly +// around that rune. +func TestRealFile_ChunkBoundary_UTF8SplitAcrossBoundary(t *testing.T) { + // "é" is 2 bytes at offset 65535..65537, split by the 65536 chunk edge. + content := strings.Repeat("A", chunkBytes-1) + "é" + strings.Repeat("B", 100) + h, path := realFileHarness(t, "utf8.txt", content) + defer h.Cleanup() + + got, _ := h.FullContent() + if got != content { + t.Fatalf("loaded buffer != original (rune split across chunks corrupted? len %d vs %d)", len(got), len(content)) + } + + // Delete the é (2 bytes) from after it. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = chunkBytes - 1 + 2 + editor.HandleBackspace() + }); err != nil { + t.Fatalf("backspace: %v", err) + } + want := strings.Repeat("A", chunkBytes-1) + strings.Repeat("B", 100) + got, _ = h.FullContent() + if got != want { + t.Fatalf("buffer = %d bytes, want %d (é not removed cleanly)", len(got), len(want)) + } + + // Insert a fresh é before the B run: exercises insert at the same spot. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = chunkBytes - 1 + editor.HandleInsert("é") + }); err != nil { + t.Fatalf("insert: %v", err) + } + got, _ = h.FullContent() + if got != content { + t.Fatalf("buffer != original after re-insert of é") + } + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != content { + t.Fatalf("disk != original after é round-trip") + } +} + +// TestRealFile_ChunkBoundary_EditsAtBothEnds performs inserts at byte 0 and at +// EOF on a multi-chunk file, the two extreme positions relative to chunks. +func TestRealFile_ChunkBoundary_EditsAtBothEnds(t *testing.T) { + content := lineFile(500) + h, path := realFileHarness(t, "boundary.txt", content) + defer h.Cleanup() + + original, _ := h.FullContent() + + // Prepend at byte 0. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 0 + editor.HandleInsert("TOP\n") + }); err != nil { + t.Fatalf("prepend: %v", err) + } + + // Append at EOF. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = int(st.Editor.ChunkedBuffer.FileLen()) + editor.HandleInsert("BOTTOM\n") + }); err != nil { + t.Fatalf("append: %v", err) + } + want := "TOP\n" + original + "BOTTOM\n" + got, _ := h.FullContent() + if got != want { + t.Fatalf("buffer mismatch after end edits (len %d vs %d)", len(got), len(want)) + } + // 501 original entries + 2 (prepend + append). + checkLineIndex(t, h, want, 503) + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != want { + t.Fatalf("disk mismatch after end edits") + } +} diff --git a/internal/test/e2e/real_file_edit_test.go b/internal/test/e2e/real_file_edit_test.go new file mode 100644 index 0000000..ea73eb0 --- /dev/null +++ b/internal/test/e2e/real_file_edit_test.go @@ -0,0 +1,426 @@ +package e2e_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "gioui.org/io/key" + "pad/internal/editor" + "pad/internal/io/pool/real" + "pad/internal/test/e2e" + "pad/internal/ui" +) + +// realFileHarness writes content to /name, builds a harness whose +// browser root and editor both use the real filesystem, opens /name and waits +// for the load + line index. Returns the harness and the on-disk path. +func realFileHarness(t *testing.T, name, content string) (*e2e.Harness, string) { + t.Helper() + dir := t.TempDir() + diskPath := filepath.Join(dir, name) + if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/")) + h.Run() + + if err := h.WithState(func(st *editor.State) { editor.GoToBrowser(nil) }); err != nil { + t.Fatalf("GoToBrowser: %v", err) + } + time.Sleep(100 * time.Millisecond) + if err := h.WithState(func(st *editor.State) { editor.OpenFile("/" + name) }); err != nil { + t.Fatalf("OpenFile: %v", err) + } + // "Loaded" means both async results are fully applied: the ReadFile + // content (len(FullContent) == FileLen) AND the BuildLineIndex result + // (LineIndex.Size == FileLen). Waiting on FileLen>0 && LineIndex!=nil + // alone is a race: stat sets FileLen, and the two worker results can + // interleave with test edits. + loaded := false + for i := 0; i < 100; i++ { + v, err := h.Inspect(func(st *editor.State) any { + cb := st.Editor.ChunkedBuffer + if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil { + return false + } + full, err := cb.FullContent() + return err == nil && int64(len(full)) == cb.FileLen() && + cb.LineIndex.Size == cb.FileLen() + }) + if err != nil { + t.Fatalf("Inspect: %v", err) + } + if v.(bool) { + loaded = true + break + } + time.Sleep(50 * time.Millisecond) + } + if !loaded { + t.Fatal("timed out waiting for real file to load") + } + return h, diskPath +} + +// readDisk reads the on-disk content of a test file. +func readDisk(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading disk: %v", err) + } + return string(data) +} + +// TestRealFile_BasicInsert verifies an insert on a small real file lands in +// the buffer, flushes, and matches on disk. +func TestRealFile_BasicInsert(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "hello world") + defer h.Cleanup() + + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 5 + editor.HandleInsert(" there") + }); err != nil { + t.Fatalf("insert: %v", err) + } + got, _ := h.FullContent() + if got != "hello there world" { + t.Fatalf("buffer = %q, want %q", got, "hello there world") + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "hello there world" { + t.Fatalf("disk = %q, want %q", disk, "hello there world") + } +} + +// TestRealFile_BackspaceAndDelete verifies deletion edits on a real file, +// including across the line-index update path. +func TestRealFile_BackspaceAndDelete(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "0123456789") + defer h.Cleanup() + + // Backspace at 5 deletes '4' (index 4); cursor moves to 4 (before '5'). + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 5 + editor.HandleBackspace() + }); err != nil { + t.Fatalf("backspace: %v", err) + } + got, _ := h.FullContent() + if got != "012356789" { + t.Fatalf("buffer = %q, want %q", got, "012356789") + } + // Delete at cursor 4 removes '5' (the character now at index 4). + if err := h.WithState(func(st *editor.State) { + editor.HandleDelete() + }); err != nil { + t.Fatalf("delete: %v", err) + } + got, _ = h.FullContent() + if got != "01236789" { + t.Fatalf("buffer = %q, want %q", got, "01236789") + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "01236789" { + t.Fatalf("disk = %q, want %q", disk, "01236789") + } +} + +// TestRealFile_IMEReplaceRange simulates an IME commit replacing a range +// (swipe-to-delete + replacement semantics) on a real file. +func TestRealFile_IMEReplaceRange(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "hello world") + defer h.Cleanup() + + // IME replaces [0,5) "hello" with "goodbye". + if err := h.WithState(func(st *editor.State) { + editor.HandleReplaceRange(0, 5, "goodbye") + }); err != nil { + t.Fatalf("replace: %v", err) + } + got, _ := h.FullContent() + if got != "goodbye world" { + t.Fatalf("buffer = %q, want %q", got, "goodbye world") + } + if pos, _ := h.CursorPosition(); pos != 7 { + t.Fatalf("cursor = %d, want 7", pos) + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "goodbye world" { + t.Fatalf("disk = %q, want %q", disk, "goodbye world") + } +} + +// TestRealFile_IMEInsertAtCaret simulates a plain IME commit (empty range at +// the caret) through the real HandleKeyDown event path. +func TestRealFile_IMEInsertAtCaret(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "abc") + defer h.Cleanup() + + if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 3 }); err != nil { + t.Fatalf("set cursor: %v", err) + } + // Go through the full event handler, as main.go delivers it. + h.SendInput([]ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 3, End: 3}, Text: "def"}}, + }) + time.Sleep(50 * time.Millisecond) + got, _ := h.FullContent() + if got != "abcdef" { + t.Fatalf("buffer = %q, want %q", got, "abcdef") + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "abcdef" { + t.Fatalf("disk = %q, want %q", disk, "abcdef") + } +} + +// TestRealFile_AutosavePersists verifies the 1s-debounced autosave writes the +// edit to disk without an explicit Flush. +func TestRealFile_AutosavePersists(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "before") + defer h.Cleanup() + + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 6 + editor.HandleInsert("-after") + }); err != nil { + t.Fatalf("insert: %v", err) + } + + // Wait for the autosave debounce (1 s) plus margin. + deadline := time.Now().Add(5 * time.Second) + for { + if disk := readDisk(t, path); disk == "before-after" { + break + } + if time.Now().After(deadline) { + t.Fatalf("disk = %q, want %q (autosave did not fire)", readDisk(t, path), "before-after") + } + time.Sleep(100 * time.Millisecond) + } +} + +// TestRealFile_ShiftSelectionInsert verifies shift+arrow selection through the +// real key event path and that typing replaces the selection; also checks the +// selection reaches the rendered TextField element. +func TestRealFile_ShiftSelectionInsert(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "hello world") + defer h.Cleanup() + + if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 0 }); err != nil { + t.Fatalf("set cursor: %v", err) + } + // Select "hello" (5 chars) with shift+right. + for i := 0; i < 5; i++ { + h.SendInput([]ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}}, + }) + } + time.Sleep(50 * time.Millisecond) + + // Selection must be visible in state... + v, err := h.Inspect(func(st *editor.State) any { + return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd} + }) + if err != nil { + t.Fatalf("inspect: %v", err) + } + sel := v.([2]int) + s, e := sel[0], sel[1] + if s != 0 || e != 5 { + t.Fatalf("selection = [%d,%d), want [0,5)", s, e) + } + + // ...and in the rendered TextField element (window-relative; at scroll 0 + // window == file, so same values). Only the latest frame matters: earlier + // frames predate the selection. + time.Sleep(100 * time.Millisecond) // let the post-input frame be captured + frames := h.GetFrames() + last := frames[len(frames)-1] + var found bool + for _, elem := range last { + if tf, ok := elem.(ui.TextField); ok && tf.ID() == "editor_text" { + found = true + if tf.SelectionStart != 0 || tf.SelectionEnd != 5 { + t.Fatalf("TextField selection = [%d,%d), want [0,5)", tf.SelectionStart, tf.SelectionEnd) + } + } + } + if !found { + t.Fatal("no editor_text TextField in latest frame") + } + + // Typing replaces the selection. + h.SendInput([]ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: key.EditEvent{Range: key.Range{Start: 5, End: 5}, Text: "goodbye"}}, + }) + time.Sleep(50 * time.Millisecond) + got, _ := h.FullContent() + if got != "goodbye world" { + t.Fatalf("buffer = %q, want %q", got, "goodbye world") + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "goodbye world" { + t.Fatalf("disk = %q, want %q", disk, "goodbye world") + } +} + +// TestRealFile_ShiftSelectionBackspace verifies backspace deletes a +// shift-selected range through the real key event path. +func TestRealFile_ShiftSelectionBackspace(t *testing.T) { + h, path := realFileHarness(t, "small.txt", "0123456789") + defer h.Cleanup() + + if err := h.WithState(func(st *editor.State) { st.Editor.CursorPosition = 2 }); err != nil { + t.Fatalf("set cursor: %v", err) + } + // Shift+right x3 selects [2,5) = "234". + for i := 0; i < 3; i++ { + h.SendInput([]ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameRightArrow, Shift: true}}, + }) + } + h.SendInput([]ui.InputEvent{ + {Handler: editor.HandleKeyDown, Data: ui.KeyEvent{Name: key.NameDeleteBackward, Shift: false}}, + }) + time.Sleep(50 * time.Millisecond) + got, _ := h.FullContent() + if got != "0156789" { + t.Fatalf("buffer = %q, want %q", got, "0156789") + } + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "0156789" { + t.Fatalf("disk = %q, want %q", disk, "0156789") + } +} + +// TestRealFile_MultilineEdit verifies insert and backspace across line +// boundaries keep the line index consistent (line count + offsets). +func TestRealFile_MultilineEdit(t *testing.T) { + h, path := realFileHarness(t, "multi.txt", "aaa\nbbb\nccc") + defer h.Cleanup() + + // Newline at end of line 1 -> 4 lines. + if err := h.WithState(func(st *editor.State) { + st.Editor.CursorPosition = 7 // after "bbb" + editor.HandleInsert("\n") + }); err != nil { + t.Fatalf("insert: %v", err) + } + checkLineIndex(t, h, "aaa\nbbb\n\nccc", 4) + + // Backspace #1 removes the inserted '\n' (cursor is right after it). + if err := h.WithState(func(st *editor.State) { + editor.HandleBackspace() + }); err != nil { + t.Fatalf("backspace 1: %v", err) + } + checkLineIndex(t, h, "aaa\nbbb\nccc", 3) + + // Backspace #2 removes the trailing 'b' of line 2. + if err := h.WithState(func(st *editor.State) { + editor.HandleBackspace() + }); err != nil { + t.Fatalf("backspace 2: %v", err) + } + checkLineIndex(t, h, "aaa\nbb\nccc", 3) + + if err := h.Flush(); err != nil { + t.Fatalf("flush: %v", err) + } + if disk := readDisk(t, path); disk != "aaa\nbb\nccc" { + t.Fatalf("disk = %q, want %q", disk, "aaa\nbb\nccc") + } +} + +// checkLineIndex verifies buffer content and that the incrementally-maintained +// line index matches the expected line count and per-line offsets. +// lo/hi/seg bound a failure-report window around offset at. +func lo(at, n int) int { + if at < n { + return 0 + } + return at - n +} + +func hi(at, n int) int { return at + n } +func seg(s string, at, n int) string { + start := lo(at, n) + end := at + n + if end > len(s) { + end = len(s) + } + if start > end { + start = end + } + return s[start:end] +} + +func checkLineIndex(t *testing.T, h *e2e.Harness, wantContent string, wantLines int) { + t.Helper() + got, err := h.FullContent() + if err != nil { + t.Fatalf("FullContent: %v", err) + } + if got != wantContent { + // Report compactly: large files make %q messages megabytes long. + at := 0 + for at < len(got) && at < len(wantContent) && got[at] == wantContent[at] { + at++ + } + t.Fatalf("buffer mismatch at byte %d: got %d bytes, want %d\ngot [%d:%d]=%q\nwant [%d:%d]=%q", + at, len(got), len(wantContent), lo(at, 40), hi(at, 40), seg(got, at, 40), lo(at, 40), hi(at, 40), seg(wantContent, at, 40)) + } + diag, err := h.Inspect(func(st *editor.State) any { + li := st.Editor.ChunkedBuffer.LineIndex + if li == nil { + return "nil index" + } + if li.LineCount() != wantLines { + return fmt.Sprintf("linecount=%d want=%d size=%d head=%v", li.LineCount(), wantLines, li.Size, li.Offsets[:min(10, len(li.Offsets))]) + } + head := "" + if len(li.Offsets) > 10 { + head = fmt.Sprintf(" head=%v", li.Offsets[:10]) + } + // Offsets must match a fresh computation from the content. + offset := 0 + for line := 0; line < wantLines; line++ { + if li.ByteOffset(line) != offset { + return fmt.Sprintf("mismatch at line %d: index=%d want=%d size=%d%s", + line, li.ByteOffset(line), offset, li.Size, head) + } + idx := strings.IndexByte(got[offset:], '\n') + if idx < 0 { + break + } + offset += idx + 1 + } + return "ok" + }) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if diag.(string) != "ok" { + t.Fatalf("line index inconsistent: %s (content=%d bytes, want %d lines)", diag, len(wantContent), wantLines) + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index 148e590..bd6664e 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -187,6 +187,11 @@ type TextField struct { Focused bool Multiline bool CursorPosition int + // SelectionStart/SelectionEnd are byte offsets into Value (the visible + // window); -1 means no selection. Used for the IME selection push and the + // in-app highlight. + SelectionStart int + SelectionEnd int ScrollOffset Dp VisibleLines []Line WordWrap bool @@ -214,6 +219,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { // 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 @@ -240,14 +246,40 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { r.lastSnippet = snippet gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet}) } - // 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). Push only when it moves, so a static caret does not reset - // the IME every frame. - caret := runeCount(tf.Value, tf.CursorPosition) - if caret != r.lastSelCaret { - r.lastSelCaret = caret - gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, Caret: key.Caret{}}) + // 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 { + 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{}}) } } else if r.lastIMEField == tf.id { // This (previously-focused) field lost focus: forget it so the next focus @@ -255,9 +287,10 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { r.lastIMEField = "" r.lastWasFocused = false r.lastSnippet = key.Snippet{} + r.lastSelStart = -1 r.lastSelCaret = -1 } - 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, tf.SelectionStart, tf.SelectionEnd) } // runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a @@ -278,7 +311,7 @@ func runeCount(s string, bytePos int) int { } // 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, selStart, selEnd int, interactions []Interaction) TextField { return TextField{ id: id, region: region, @@ -290,6 +323,8 @@ func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOf WrapWidth: wrapWidth, ScrollOffset: scrollOffset, CursorPosition: cursorPos, + SelectionStart: selStart, + SelectionEnd: selEnd, } } @@ -829,6 +864,15 @@ type Interactive interface { } // Point defines a 2D coordinate in device-independent pixels (Dp). +// KeyEvent is a key press with its modifier state, delivered as the Data of +// an InputEvent to a ui.KeyDown handler. It replaces passing a bare key.Name +// (which discarded modifier state) so handlers can distinguish e.g. +// shift+arrow (extend selection) from plain arrow (move cursor). +type KeyEvent struct { + Name key.Name + Shift bool +} + type Point struct { X, Y Dp } diff --git a/internal/ui/render.go b/internal/ui/render.go index c57f486..b122bc5 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -75,7 +75,8 @@ type Renderer struct { lastIMEField string lastWasFocused bool lastSnippet key.Snippet - lastSelCaret int // window-relative rune index of last-pushed caret; -1 = none + 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 } // New creates a new Renderer. @@ -481,7 +482,7 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col // detection via WrapHeuristically. Long words overflow the wrap width. // Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent // of glyph metrics. The shaper's first.Y accounts for line spacing. -func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos int) { +func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int) { if str == "" { return } @@ -512,7 +513,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w y := reg.Y - scrollOffset col := Color{R: 0, G: 0, B: 0, A: 255} // black text - m := op.Record(gtx.Ops) + // Pass 1: collect glyph lines and per-glyph layout data (no drawing yet), + // so the selection highlight can be emitted before the text ops and render + // underneath it. + var lines [][]text.Glyph var glyphs [32]text.Glyph line := glyphs[:0] lineCount := 0 @@ -522,6 +526,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w layout.LineHeight = Dp(float32(lineHeightSp)) byteOffset := 0 layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) + flushLine := func() { + lines = append(lines, append([]text.Glyph(nil), line...)) + line = line[:0] + } for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() { // Record layout data for this glyph. // g.X is in fixed.Int26_6 — shift >> 6 for device pixels, divide by scale for Dp. @@ -539,8 +547,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w line = append(line, g) if g.Flags&text.FlagLineBreak != 0 || cap(line)-len(line) == 0 { - r.drawLine(gtx, line, reg.X, y, col) - line = line[:0] + flushLine() if g.Flags&text.FlagLineBreak != 0 { lineCount++ layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) @@ -548,9 +555,41 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w } } if len(line) > 0 { - r.drawLine(gtx, line, reg.X, y, col) + flushLine() lineCount++ } + + // Pass 2: selection highlight (translucent blue), one rect per covered + // glyph, emitted before the text so glyphs draw on top of it. + if selStart >= 0 && selEnd > selStart { + for i := range layout.ByteOffsets { + b0 := layout.ByteOffsets[i] + b1 := len(str) + if i+1 < len(layout.ByteOffsets) { + b1 = layout.ByteOffsets[i+1] + } + if b0 >= selEnd || b1 <= selStart { + continue + } + hx := reg.X + layout.X[i] + hy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize) + hw := layout.Advance[i] + hh := Dp(r.theme.FontSize) * 1.2 + rect := clip.Rect{ + Min: image.Point{X: int(r.toPx(hx)), Y: int(r.toPx(hy))}, + Max: image.Point{X: int(r.toPx(hx + hw)), Y: int(r.toPx(hy + hh))}, + }.Op().Push(gtx.Ops) + paint.ColorOp{Color: color.NRGBA{R: 0x33, G: 0x99, B: 0xFF, A: 0x59}}.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + rect.Pop() + } + } + + // Pass 3: the text itself. + m := op.Record(gtx.Ops) + for _, ln := range lines { + r.drawLine(gtx, ln, reg.X, y, col) + } call := m.Stop() call.Add(gtx.Ops)