Add text selection, real-file e2e tests, and Android arrow-key support
Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
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. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
This commit is contained in:
parent
3c8017f871
commit
ec11abf8f1
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
12
doc/spec.md
12
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. |
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
internal/editor/selection_test.go
Normal file
347
internal/editor/selection_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
224
internal/test/e2e/real_file_chunk_boundary_test.go
Normal file
224
internal/test/e2e/real_file_chunk_boundary_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
426
internal/test/e2e/real_file_edit_test.go
Normal file
426
internal/test/e2e/real_file_edit_test.go
Normal file
|
|
@ -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 <tempdir>/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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user