From 7239b1e7aeabde75f570f840935cba6fbfa26e07 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 18 Aug 2026 13:39:25 -0400 Subject: [PATCH] Handle drags: restore cross-line selection via relative line mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- doc/development_plan.md | 41 +++++++----- internal/editor/state.go | 56 ++++++++++++----- internal/editor/touch_selection_test.go | 84 +++++++++++++++++-------- 3 files changed, 126 insertions(+), 55 deletions(-) diff --git a/doc/development_plan.md b/doc/development_plan.md index 382afea..5d6863d 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -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 diff --git a/internal/editor/state.go b/internal/editor/state.go index 6da0ebb..0f155f2 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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 diff --git a/internal/editor/touch_selection_test.go b/internal/editor/touch_selection_test.go index 6465f9d..5494f10 100644 --- a/internal/editor/touch_selection_test.go +++ b/internal/editor/touch_selection_test.go @@ -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) {