Selection handles: capture base line at grab (fix jitter runaway)

The relative-line mapping resolved the anchor's base line from the
anchor's CURRENT byte on every drag event. Once the anchor crossed a
line, its own movement fed back into its target: a finger jittering
near a line boundary added one more line per event and raced the
anchor to the bottom of the file (reported: tiny vertical movements
jump the anchor off-screen).

Capture the anchor's visual line once at the grab (SelDragPressLine)
and compute the target from that fixed base. A jitter test
(TestSelDrag_EndHandle_JitterNearLineBoundary_Stable) pins the
runaway: mutation-verified against the per-event re-resolution.
This commit is contained in:
Greg Pomerantz 2026-08-18 15:13:05 -04:00
parent 7239b1e7ae
commit 110c92e3fa
3 changed files with 115 additions and 19 deletions

View File

@ -87,12 +87,31 @@ on an idle window and every menu tap was swallowed.
## 2. Evidence (verified against gioui.org v0.10.0 sources, the live repo's version)
1. **`widget.Editor` is not virtualized** — it shapes the *entire* document per
invalidation and keeps a ~200 B/rune in-memory `glyphIndex`. Measured on this
box: 1 MB ≈ 17 ms/keystroke & ~0.32 GB; 3 MB ≈ 1.7 GB; 4 MB OOM-killed the host.
Planning figure **~0.5 GB of RAM per 1 MB of text**. A phone cannot edit 10 MB
in `widget.Editor`. This is *why* the live repo went the chunked-buffer route,
and it's the reason v1's "just use the widget" was wrong for large files.
1. **`widget.Editor` is not virtualized** — `textView.layoutText` seeks to the
start and shapes the *entire* document on every invalidation (text edit,
size/param change), with an infinite viewport; the shaper's `document.reset()`
is `lines = lines[:0]`, so the backing array of the largest layout is retained
forever. **Re-benchmarked 2026-08-18 against v0.10.0** (plain `text.Shaper`,
whole-document shape, the widget's exact call pattern; x86_64 VM):
| doc | shape time (per edit/keystroke) | retained heap |
|---|---|---|
| 1 MB | ~120 ms | ~5 MB |
| 2 MB | ~0.4 s | ~7 MB |
| 5 MB | ~3.4 s | ~15 MB |
| 10 MB | ~14 s | ~29 MB |
The **CPU wall is the decision argument**: a full re-shape on every keystroke
is already sluggish at 1 MB (~8 fps of edits) and unusable above ~2 MB; a
phone CPU (310× slower than this VM) is worse. Memory grows only ~3× the
text size with the raw shaper — the earlier "~0.5 GB per MB" figures (which
likely included Android PSS overhead / an older version) do not reproduce, but
they are not needed: the linear per-edit shape cost alone rules out
`widget.Editor` for multi-MB files. This is *why* the live repo went the
chunked-buffer route, and it's the reason v1's "just use the widget" was
wrong for large files. (For small files — sub-MB notes — `widget.Editor`
would be adequate; the custom editor is only strictly required above ~1 MB,
but the app's 50 MB target is 50× that.)
2. **The Android IME works through the op layer, not the widget.** The
`window`'s editor state (`app.Window` → `input.EditorState{Selection, Snippet}`)
is what `GioInputConnection` (Android's `InputConnection`) reads. It is fed by
@ -975,13 +994,18 @@ downward, dragging the start handle up extends it upward. The mapping is
relative (displacement from the grab, not the finger's absolute line)
precisely because the grab is usually a line or more below the anchor:
tracking the finger's absolute line would first drag the anchor the wrong
way, through a collapse, before it ever reached the anchor's line.
Regressions in `touch_selection_test.go`:
way, through a collapse, before it ever reached the anchor's line. The
base line is captured **once at the grab** and never re-resolved from the
anchor's current byte: that would feed the anchor's own movement back
into its target line, and a finger jittering near a line boundary would
race the anchor off the screen (one extra line per event — reported as
"tiny vertical movements jump the anchor to the top/bottom of the
screen"). Regressions in `touch_selection_test.go`:
`TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection` (the original
bug's geometry), `TestSelDrag_EndHandle_DragDownExtendsAcrossLines`,
`TestSelDrag_StartHandle_DragUpExtendsToLineAbove` — all
mutation-verified against both the pre-fix mapping and the
absolute-finger-line variant.
`TestSelDrag_StartHandle_DragUpExtendsToLineAbove`,
`TestSelDrag_EndHandle_JitterNearLineBoundary_Stable` (the runaway) — all
mutation-verified against the pre-fix variants.
**Menu anchors to the stable end.** While a start-handle drag is in
progress the selection start is the moving end, so the menu anchors to

View File

@ -108,10 +108,15 @@ type EditorState struct {
SelDragWhich int
SelDragRel int
// SelDragPressY is the text-local Y of the first event of an in-progress
// handle/body drag (the grab). Start/end handles move relative to this
// (see handleAnchorPos).
SelDragPressY float64
Filename string
// handle/body drag (the grab), and SelDragPressLine the visual line of
// the dragged anchor AT THE GRAB. Start/end handles move relative to
// these (see handleAnchorPos). The base line must be captured once at the
// grab: re-resolving it from the anchor's current byte on every event
// makes the anchor's own movement feed back into its target, and a
// jittering finger near a line boundary races the anchor off the screen.
SelDragPressY float64
SelDragPressLine int
Filename string
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
// editor shows a "too large to edit" notice instead of content (the
// browser can still list the file).
@ -1109,6 +1114,16 @@ func selDragMove(which int, x, y ui.Dp) {
e.SelDragging = true
e.SelDragWhich = which
e.SelDragPressY = localY
if which == 0 {
if l, ok2 := visualLineOfByte(e.SelectionStart - glyphBase()); ok2 {
e.SelDragPressLine = l
}
}
if which == 1 {
if l, ok2 := visualLineOfByte(e.SelectionEnd - glyphBase()); ok2 {
e.SelDragPressLine = l
}
}
if which == 2 && ok {
// Body drag: the grab fixes the finger's offset from the selection
// start; the selection itself moves on later events.
@ -1173,10 +1188,10 @@ func selDragMove(which int, x, y ui.Dp) {
// a deliberate vertical drag crosses lines only after the finger has moved
// past half a line height from the grab.
func handleAnchorPos(e *EditorState, anchorByte int, localX, localY float64) (int, bool) {
line, ok := visualLineOfByte(anchorByte - glyphBase())
if !ok {
return textPosFromLocalPoint(localX, localY)
}
// Base line is the anchor's line at the GRAB (captured in selDragMove),
// never the anchor's current line: the anchor's own movement must not
// feed back into its target.
line := e.SelDragPressLine
lh := float64(EffectiveLineHeight())
if lh > 0 {
target := line + int(math.Round((localY-e.SelDragPressY)/lh))

View File

@ -479,6 +479,63 @@ func checkSelection12_18(t *testing.T) {
}
}
// touchSelState3Lines is like touchSelState2Lines with a third line.
func touchSelState3Lines() {
content := "hello world\nsecond line\nthird line here\n"
TheState = NewState()
TheState.Editor.Buffer = content
TheState.Editor.CursorPosition = len(content)
TheState.EditorRegion = ui.Region{X: 16, Y: 100, W: 379, H: 700}
TheState.ScrollOffset = 0
TheState.Editor.IMEWindowStartByte = 0
TheState.Editor.IMEWindowText = content
lh := ui.Dp(16.8)
bo := []int{}
xs := []ui.Dp{}
s := []ui.Dp{}
ad := []ui.Dp{}
lines := []struct{ start, n int }{{0, 11}, {12, 11}, {24, 15}}
for li, l := range lines {
for j := 0; j < l.n; j++ {
bo = append(bo, l.start+j)
xs = append(xs, ui.Dp(10*j))
s = append(s, ui.Dp(float64(lh)*float64(li)))
ad = append(ad, 10)
}
}
TheState.Editor.GlyphLayout = ui.GlyphLayout{
LineHeight: lh,
ByteOffsets: bo,
X: xs,
Y: s,
Advance: ad,
}
}
// TestSelDrag_EndHandle_JitterNearLineBoundary_Stable is a regression test
// for a feedback runaway: the target line must be computed from the
// anchor's line AT THE GRAB. Re-resolving the base line from the anchor's
// current byte on every event makes the anchor's own movement feed into its
// target: once the anchor crosses a line, every further event adds another
// line, and a jittering finger near a boundary races the anchor to the
// bottom of the file.
func TestSelDrag_EndHandle_JitterNearLineBoundary_Stable(t *testing.T) {
touchSelState3Lines()
HandleLongPressAt(18, 108) // selects "hello" [0,5)
checkInitialSelection(t)
// End handle (which=1), anchor byte 5 on line 0. Grab at local (52, 25).
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25})
checkInitialSelection(t)
// The finger hovers just past the half-line boundary (dY = 10, 11, 10
// around the 8.4 threshold) and jitters. The anchor must settle one line
// down (byte 17, the 'n' of "second") and STAY there — not creep to
// line 2 (byte 29+) or further with each event.
for _, dy := range []int{35, 36, 35, 36, 35} {
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: ui.Dp(100 + dy)})
assertSelection(t, 0, 17)
}
}
func checkInitialSelection(t *testing.T) {
t.Helper()
s := TheState.Editor