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:
parent
275a78efaa
commit
7239b1e7ae
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user