Handle drags: restore cross-line selection via relative line mapping

The line-lock from the previous fix made anchors immovable across lines —
dragging a handle vertically no longer extended the selection, losing the
native multi-line behaviour.

New contract: a handle anchor moves RELATIVE to its own visual line. The
finger's vertical displacement from the grab position, in whole visual line
heights, selects the target line (under half a line: the anchor's own
line); the finger's x is projected onto that line. A stationary or
horizontal drag never moves the anchor off its line (the grab-box press
stays safe — the original bug is gone), while a deliberate vertical drag
walks the anchor across lines. The mapping is relative, not the finger's
absolute line, because the 48dp grab box is centred below the line: a low
press tracking the finger's absolute line would first drag the anchor the
wrong way, through a collapse, before reaching the anchor's line.

Verified on device: end handle dragged down extends the selection across
the newline; start handle dragged up extends it onto the previous line;
horizontal drags still shrink along the line without clearing. Tests
mutation-verified against both the pre-fix mapping and the
absolute-finger-line variant. Docs section 17 updated.
This commit is contained in:
Greg Pomerantz 2026-08-18 13:39:25 -04:00
parent 275a78efaa
commit 7239b1e7ae
3 changed files with 126 additions and 55 deletions

View File

@ -949,11 +949,10 @@ tapping elsewhere).
## 17. Handle drags, menu anchoring, and the left-edge gesture (2026-08-18)
**Line projection for handle drags.** A selection handle's 48dp grab box
(straddling two visual lines, because the teardrop hangs off the line's
bottom edge) is larger than the line it belongs to. A finger in the lower
half of the box maps, by pure y-to-line, to the NEIGHBOURING line.
Mapping the finger to its own line was fatal for a start-handle drag: the
**Relative line mapping for handle drags.** A selection handle's 48dp grab
box is centred BELOW its line (the teardrop hangs off the line's bottom
edge), so a press usually lands on the neighbouring line. Mapping the
finger to its own line was fatal for a start-handle drag: the
neighbouring-line byte is usually past the other handle, the clamp
collapses the selection to zero length, the selection is cleared — and a
cleared selection un-registers the drag op, so Gio's router silently
@ -961,18 +960,28 @@ stops delivering drag events to it (an inactive handler is deleted at the
next frame boundary without a cancel). Every downstream symptom — the
"stream cutoff", a tap landing on release, the system back gesture
firing on subsequent edge swipes — was a consequence of that single
collapse, not a system-side touch filter.
collapse, not a system-side touch filter. The first attempt at a fix
locked the anchor to its own line entirely, which removed the bug but
also removed the native ability to drag a handle across lines.
Contract: a handle drag projects the finger's X onto the ANCHOR's own
visual line (the line the handle belongs to, resolved the same way the
renderer resolves the handle position). The anchor therefore never
crosses a line during a handle drag; vertical finger movement is ignored.
The trade-off vs. native (native can walk the anchor to another line by
dragging across lines) is accepted: the reported bug was worse than the
sacrifice, and body drags still move whole selections across lines.
Regression: `TestSelDrag_*_PressOnLineBelow_*` in
`touch_selection_test.go` (both mutation-verified against the pre-fix
mapping).
Contract: a handle anchor moves RELATIVE to its own visual line. The
finger's vertical displacement from the GRAB position, in whole visual
line heights, selects the target line (less than half a line: the
anchor's own line); the finger's X is projected onto that line. A
stationary or horizontal drag therefore never moves the anchor off its
line (the grab is safe), while a deliberate vertical drag walks the
anchor across lines — dragging the end handle down extends the selection
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`:
`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.
**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

@ -3,6 +3,7 @@ package editor
import (
"fmt"
"log"
"math"
"sort"
"strings"
"time"
@ -106,7 +107,11 @@ type EditorState struct {
SelDragging bool
SelDragWhich int
SelDragRel int
Filename string
// 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
// 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).
@ -1103,6 +1108,7 @@ func selDragMove(which int, x, y ui.Dp) {
if !e.SelDragging {
e.SelDragging = true
e.SelDragWhich = which
e.SelDragPressY = localY
if which == 2 && ok {
// Body drag: the grab fixes the finger's offset from the selection
// start; the selection itself moves on later events.
@ -1120,25 +1126,16 @@ func selDragMove(which int, x, y ui.Dp) {
}
switch e.SelDragWhich {
case 0: // start handle
// Project the finger's x onto the ANCHOR's own visual line. The grab
// box is 48dp and straddles the neighbouring line, so a finger on the
// lower half of the box maps to the line below; mapping to the finger's
// line used to clamp the anchor onto the other handle and collapse
// (clear) the selection on the very first drag event.
if line, ok2 := visualLineOfByte(e.SelectionStart - glyphBase()); ok2 {
if p2, ok3 := textPosOnLineAtX(line, localX); ok3 {
pos = p2
}
if p2, ok2 := handleAnchorPos(e, e.SelectionStart, localX, localY); ok2 {
pos = p2
}
if pos > e.SelectionEnd {
pos = e.SelectionEnd
}
SetSelection(pos, e.SelectionEnd)
case 1: // end handle
if line, ok2 := visualLineOfByte(e.SelectionEnd - glyphBase()); ok2 {
if p2, ok3 := textPosOnLineAtX(line, localX); ok3 {
pos = p2
}
if p2, ok2 := handleAnchorPos(e, e.SelectionEnd, localX, localY); ok2 {
pos = p2
}
if pos < e.SelectionStart {
pos = e.SelectionStart
@ -1160,6 +1157,37 @@ func selDragMove(which int, x, y ui.Dp) {
}
}
// handleAnchorPos resolves the new anchor byte for a start/end-handle drag
// event. The anchor moves RELATIVE to its own visual line: the finger's
// vertical displacement from the grab (in whole visual lines) selects the
// target line, and the finger's x is projected onto that line.
//
// This gives the native behaviour — dragging a handle down extends the
// selection across lines, dragging it up shrinks from the far side — while
// keeping the grab safe. The 48dp grab box is centred BELOW the line (the
// teardrop hangs off the line's bottom edge), so the press usually lands on
// the neighbouring line; mapping the press to its own line used to clamp the
// anchor onto the other handle and clear the selection on the very first
// drag event. With the relative mapping a stationary or horizontal drag
// (displacement under half a line) never moves the anchor off its line, and
// 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)
}
lh := float64(EffectiveLineHeight())
if lh > 0 {
target := line + int(math.Round((localY-e.SelDragPressY)/lh))
if target < 0 {
target = 0
}
return textPosOnLineAtX(target, localX)
}
return textPosOnLineAtX(line, localX)
}
// visualLineOfByte returns the visual line (0-based within the shaped
// window) that holds the insertion point at the given window-relative byte
// offset, using the same rule the renderer uses to place the handles

View File

@ -408,41 +408,75 @@ func touchSelState2Lines() {
}
// TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection is a regression
// test for the line-projection fix: the start handle's 48dp grab box
// straddles the neighbouring line, so a finger in the lower half of the box
// maps (by pure y-to-line) to the line BELOW the anchor. Mapping the finger
// to its own line yielded a byte past SelectionEnd, the clamp collapsed the
// selection to zero length, and the selection vanished on the very first
// drag event (the drag op then un-registered and the "stream" appeared to be
// cut off). The anchor must instead be projected onto its OWN visual line,
// so the same finger position shrinks the selection along that line.
// test: the start handle's 48dp grab box is centred BELOW the line (the
// teardrop hangs off the line's bottom edge), so a press in the lower half
// of the box lands on the neighbouring line. The press (zero displacement
// from the grab) must not move the anchor off its own line; mapping it to
// the finger's own line used to clamp the anchor onto the other handle and
// clear the selection on the very first drag event.
func TestSelDrag_StartHandle_PressOnLineBelow_KeepsSelection(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(18, 108) // selects "hello" [0,5)
checkInitialSelection(t)
// First drag event of the start handle (which=0): finger at local
// (22, 25) — localY 25 is on line 1 (line height 16.8), localX 22 is
// above the 'c' of "second" on line 1 (byte 14, past SelectionEnd=5) but
// above the 'l' of "hello" on line 0 (byte 2). Pre-fix this event
// collapsed the selection to (5,5) -> cleared.
// Start handle (which=0), anchor at byte 0 (local x=0). Press at local
// (22, 25): localY 25 is on line 1 (line height 16.8), on the finger's
// own line x=22 is byte 14 (past SelectionEnd=5) — the original bug's
// exact geometry. Zero vertical displacement from the grab -> the anchor
// stays on line 0, x=22 there is byte 2: the selection shrinks to "llo"
// but is never cleared.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 22, Y: 100 + 25})
assertSelection(t, 2, 5) // "llo" — projected onto line 0, never cleared
assertSelection(t, 2, 5)
// Continue dragging left: x=12 on line 0 is byte 1.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 12, Y: 100 + 25})
assertSelection(t, 1, 5) // "ello"
}
// TestSelDrag_EndHandle_PressOnLineBelow_StaysOnAnchorLine is the end-handle
// counterpart: a finger in the lower half of the end handle's box must not
// lasso the selection across onto the next line; the end is projected onto
// its own visual line (line 0 here), so the selection only shrinks.
func TestSelDrag_EndHandle_PressOnLineBelow_StaysOnAnchorLine(t *testing.T) {
// TestSelDrag_EndHandle_DragDownExtendsAcrossLines is the multi-line
// extension: grabbing the end handle and dragging it down more than half a
// line height moves the anchor to the next visual line (relative to the
// anchor's own line), so the selection grows across the newline — the
// native behaviour.
func TestSelDrag_EndHandle_DragDownExtendsAcrossLines(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(18, 108) // selects "hello" [0,5)
// End handle (which=1), finger at local (22, 25): on line 1 that is byte
// 14 (pre-fix: the selection would extend to [0,14), across the newline),
// on line 0 it is byte 2 (post-fix: [0,2) = "he").
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 22, Y: 100 + 25})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 22, Y: 100 + 25})
assertSelection(t, 0, 2)
checkInitialSelection(t)
// End handle (which=1), anchor at byte 5 (local x=50). Grab at local
// (52, 25) — the teardrop hangs below line 0, its box centre at y≈26.8.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 25})
assertSelection(t, 0, 5) // grab alone does not move the anchor
// Drag down 20dp (past half a line height of 16.8): the anchor crosses
// to line 1, x=52 there is byte 17 (the 'n' of "second").
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 52, Y: 100 + 45})
assertSelection(t, 0, 17) // "hello world\nsecond" minus the last d
}
// TestSelDrag_StartHandle_DragUpExtendsToLineAbove is the upward
// counterpart: grabbing the line-1 start handle (whose box sits on line 2)
// and dragging it up extends the selection onto line 0.
func TestSelDrag_StartHandle_DragUpExtendsToLineAbove(t *testing.T) {
touchSelState2Lines()
HandleLongPressAt(16+15, 100+25) // line 1, x=15: selects "second" [12,18)
checkSelection12_18(t)
// Start handle (which=0), anchor at byte 12 (line 1, local x=0). Grab at
// local (0, 43) — the box centre for a line-1 handle is at y≈43.6.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 43})
checkSelection12_18(t)
// A small wobble (3dp) must NOT cross lines: still line 1, x=8 -> byte 12.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 8, Y: 100 + 46})
checkSelection12_18(t)
// Drag up 20dp from the grab: the anchor crosses to line 0, x=0 there is
// byte 0.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 0, Y: 100 + 23})
assertSelection(t, 0, 18) // "hello world\nsecond"
}
func checkSelection12_18(t *testing.T) {
t.Helper()
s := TheState.Editor
if s.SelectionStart != 12 || s.SelectionEnd != 18 {
t.Fatalf("selection = [%d,%d), want [12,18)", s.SelectionStart, s.SelectionEnd)
}
}
func checkInitialSelection(t *testing.T) {