diff --git a/cmd/pad/impl_android.go b/cmd/pad/impl_android.go index 9221217..5f141e6 100644 --- a/cmd/pad/impl_android.go +++ b/cmd/pad/impl_android.go @@ -63,6 +63,30 @@ func RunInJVM(f func(env *C.JNIEnv)) { f(env) } +// SetGestureExclusions forwards the selection-handle grab boxes (view-local +// px) to View.setSystemGestureExclusionRects on Android so drags starting on +// an edge handle are not stolen by the system back gesture (see +// jni_android.c). It is called from the frame loop, which runs on the Android +// UI thread. No-op (C-side) before API 29. +func SetGestureExclusions(rects [][4]int) { + if theJVM == nil { + return + } + RunInJVM(func(env *C.JNIEnv) { + if len(rects) == 0 { + C.SetGestureExclusions(env, 0, nil) + return + } + buf := make([]C.jint, len(rects)*4) + for i, rc := range rects { + for j, v := range rc { + buf[i*4+j] = C.jint(v) + } + } + C.SetGestureExclusions(env, C.jint(len(rects)), &buf[0]) + }) +} + func OpenFile(path string) { var env *C.JNIEnv var detach bool diff --git a/cmd/pad/impl_other.go b/cmd/pad/impl_other.go index 7282d38..a802dd0 100644 --- a/cmd/pad/impl_other.go +++ b/cmd/pad/impl_other.go @@ -13,3 +13,7 @@ var ( func handleEvent(e event.Event) { } func OpenFile(path string) { } + +// SetGestureExclusions is a no-op off Android (system gesture exclusion +// rects are an Android API 29+ feature). +func SetGestureExclusions(rects [][4]int) {} diff --git a/cmd/pad/jni_android.c b/cmd/pad/jni_android.c index b6ad006..ca270c3 100644 --- a/cmd/pad/jni_android.c +++ b/cmd/pad/jni_android.c @@ -9,8 +9,20 @@ // Global context static jobject ctx = NULL; +// g_view: global ref to the GioView, set in registerFragment. Used by +// SetGestureExclusions to reach View.setSystemGestureExclusionRects. +static jobject g_view = NULL; + void registerFragment(JNIEnv *env, jobject view) { + if (view != NULL) { + if (g_view == NULL) { + g_view = (*env)->NewGlobalRef(env, view); + } else if (g_view != view) { + (*env)->DeleteGlobalRef(env, g_view); + g_view = (*env)->NewGlobalRef(env, view); + } + } jclass cls = (*env)->GetObjectClass(env, view); jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;"); jobject l_ctx = (*env)->CallObjectMethod(env, view, mid); @@ -119,3 +131,63 @@ void open_file_in_termux(JNIEnv *env, const char *path) { (*env)->CallVoidMethod(env, ctx, startActivity, intent); } + +// Set the exclusion rects ON THE UI THREAD. This function is called from the +// Go frame loop, which runs on its own OS thread (NOT the Android UI thread +// — the JNI event pump merely enqueues events that the app goroutine drains). +// setSystemGestureExclusionRects stores the rects on the view and triggers a +// relayout; it is the RELAYOUT that reports them to the window manager, which +// owns the edge back-gesture zone. A direct cross-thread call leaves the +// window manager unaware, so the rects are applied through +// view.post(PadExcl) — a Runnable (org.gioui.PadExcl, added by the build +// scripts' smali patch) that runs on the UI thread. +void SetGestureExclusions(JNIEnv *env, jint nrects, const jint *xyxy) { + if (g_view == NULL) return; + jclass verCls = (*env)->FindClass(env, "android/os/Build$VERSION"); + if (verCls == NULL) return; + jfieldID sdkF = (*env)->GetStaticFieldID(env, verCls, "SDK_INT", "I"); + if (sdkF == NULL) return; + if ((*env)->GetStaticIntField(env, verCls, sdkF) < 29) return; + jclass rectCls = (*env)->FindClass(env, "android/graphics/Rect"); + jmethodID rectInit = (*env)->GetMethodID(env, rectCls, "", "(IIII)V"); + jclass listCls = (*env)->FindClass(env, "java/util/ArrayList"); + jmethodID listInit = (*env)->GetMethodID(env, listCls, "", "()V"); + jobject list = (*env)->NewObject(env, listCls, listInit); + if (list == NULL) return; + jmethodID addM = (*env)->GetMethodID(env, listCls, "add", "(Ljava/lang/Object;)Z"); + for (int i = 0; i < nrects; i++) { + jobject rect = (*env)->NewObject(env, rectCls, rectInit, + xyxy[i * 4], xyxy[i * 4 + 1], xyxy[i * 4 + 2], xyxy[i * 4 + 3]); + if (rect == NULL) continue; + (*env)->CallBooleanMethod(env, list, addM, rect); + (*env)->DeleteLocalRef(env, rect); + } + // Find the app class through the context's class loader. A bare + // FindClass from this (non-Java) thread resolves against the BOOT + // class loader only and would never find org.gioui.PadExcl. + jclass ctxCls = (*env)->GetObjectClass(env, ctx); + jmethodID gclM = (*env)->GetMethodID(env, ctxCls, "getClassLoader", "()Ljava/lang/ClassLoader;"); + jobject loader = (*env)->CallObjectMethod(env, ctx, gclM); + jclass loaderCls = (*env)->GetObjectClass(env, loader); + jmethodID fcM = (*env)->GetMethodID(env, loaderCls, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;"); + jstring clsName = (*env)->NewStringUTF(env, "org.gioui.PadExcl"); + jclass padExclCls = (jclass)(*env)->CallObjectMethod(env, loader, fcM, clsName); + if (padExclCls == NULL) { + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, list); + return; + } + jmethodID padExclInit = (*env)->GetMethodID(env, padExclCls, "", "(Landroid/view/View;Ljava/util/List;)V"); + jobject runnable = (*env)->NewObject(env, padExclCls, padExclInit, g_view, list); + if (runnable == NULL) { + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, list); + return; + } + jmethodID postM = (*env)->GetMethodID(env, (*env)->GetObjectClass(env, g_view), + "post", "(Ljava/lang/Runnable;)Z"); + (void)(*env)->CallBooleanMethod(env, g_view, postM, runnable); + (*env)->ExceptionClear(env); + (*env)->DeleteLocalRef(env, runnable); + (*env)->DeleteLocalRef(env, list); +} diff --git a/cmd/pad/jni_android.h b/cmd/pad/jni_android.h index 652687f..25119d7 100644 --- a/cmd/pad/jni_android.h +++ b/cmd/pad/jni_android.h @@ -12,3 +12,4 @@ jint AttachCurrentThread(JavaVM *vm, JNIEnv **p_env, void *thr_args); jint DetachCurrentThread(JavaVM *vm); jobject NewGlobalRef(JNIEnv *env, jobject o); void open_file_in_termux(JNIEnv *env, const char *path); +void SetGestureExclusions(JNIEnv *env, jint nrects, const jint *xyxy); diff --git a/cmd/pad/main.go b/cmd/pad/main.go index c032e73..bb60f99 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -117,6 +117,13 @@ func run(w *app.Window) error { go frameReceiver(w, &mu, &frame, logic.FrameChan()) go logic.Run() + // lastExcl tracks the selection-handle grab boxes last sent to + // SetGestureExclusions (Android: keeps the system back gesture from + // stealing drags that start on an edge handle). Only the JNI call when + // the set changes — the rects move every frame while a selection is + // visible/scrolling, but identical repeats are skipped. + var lastExcl [][4]int + for { switch e := w.Event().(type) { case app.DestroyEvent: @@ -209,6 +216,13 @@ func run(w *app.Window) error { if renderer.PendingLongPress() { w.Invalidate() } + // Selection-handle system-gesture exclusions (Android): forward the + // frame's handle grab boxes when they changed. Runs on the UI + // thread (the frame pump is), as View methods require. + if excl := renderer.GestureExclusions(); !exclEqual(excl, lastExcl) { + lastExcl = excl + SetGestureExclusions(excl) + } // Gather key events focusedID := frame.FocusedElementID @@ -300,6 +314,19 @@ func run(w *app.Window) error { } } +// exclEqual reports whether two exclusion-rect sets are identical. +func exclEqual(a, b [][4]int) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func frameReceiver(w *app.Window, mu *sync.Mutex, frame *editor.Frame, frameChan <-chan editor.Frame) { for { f := <-frameChan diff --git a/doc/development_plan.md b/doc/development_plan.md index 5ca6dd9..382afea 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -946,3 +946,71 @@ shorter bar, selections on the first lines flip below because there is no room above, and the menu may overlap the top bar when clamped high — both are the native toolbar's behaviour (transient, dismissed by 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 +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 +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. + +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). + +**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 +the selection END (and vice-versa the default anchor is the start). The +menu therefore never chases the finger: dragging the end handle away +leaves the menu parked by the selection start, clear of the finger's +path and (combined with the above/below contract of section 15) clear of +the selected text. Verified on device: a long vertical end-handle drag +with the menu up leaves the menu, the highlight and both handles +undisturbed. + +**The menu is never sticky-hidden while any part of the selection is +visible.** If both selection ends fall outside the shaped window (e.g. +the viewport scrolled away) but the selection still intersects the +window, the menu keeps its previous position, clamped inside the window; +it is hidden only when the whole selection is off-window, and re-anchoring +(the next selection change) re-shows it. This replaces the earlier +behaviour where an off-window anchor coordinate hid the menu +permanently. Regression: +`TestSelectionMenu_KeptVisibleWhileSelectionPartiallyOffScreen`. + +**Left-edge exclusion for the start handle.** On gesture navigation +(API 30+/35) a horizontal rightward swipe starting within ~23dp of the +left screen edge starts the system BACK gesture; the gesture previews, +cancels the in-app touch stream (ACTION_CANCEL), hides the IME and +navigates. A start handle at the beginning of a line sits at screen +x≈26px — inside that zone — so grabbing it with a horizontal flick +navigated away instead of dragging. The app cannot disable the system +back gesture, but Android lets a view opt specific rects out via +`View.setSystemGestureExclusionRects` (API 29+). The editor reports the +two selection-handle grab rects (screen px) every frame; the app +forwards them to the view. Two invariants: the call must run on the +Android UI thread (the Go frame loop is not it — it is marshalled via a +tiny `PadExcl` Runnable posted through `View.post`, generated by the +build scripts' smali patch, which must stay byte-identical between +`build_emu.sh` and `build_phone.sh`), and the rects are in the view's +coordinate system (== window-local px for the full-screen GioView). +Verified on device: with the exclusion active, horizontal drags from the +left-edge start handle no longer trigger `startBackNavigation` +(`dumpsys window` shows the exclusion region; logcat shows zero +back-gesture previews). diff --git a/internal/editor/selection_menu_track_test.go b/internal/editor/selection_menu_track_test.go index dae8ec5..ec69eac 100644 --- a/internal/editor/selection_menu_track_test.go +++ b/internal/editor/selection_menu_track_test.go @@ -93,8 +93,9 @@ func TestSelectionMenu_FollowsTextAcrossScroll(t *testing.T) { // TestSelectionMenu_ClampsWhenTextLeavesView checks the small-file edge: the // buffer is the whole window, so the anchor never "leaves"; when the selected -// text scrolls off the top, the menu pins to the top of the window (it never -// detaches into mid-screen stale territory). +// text scrolls off the top, the menu keeps its previous position clamped to +// the window (it never detaches into mid-screen stale territory and never +// vanishes while the selection can still be reached by scrolling back). func TestSelectionMenu_ClampsWhenTextLeavesView(t *testing.T) { lh := menuTrackState(t) @@ -105,16 +106,35 @@ func TestSelectionMenu_ClampsWhenTextLeavesView(t *testing.T) { if !e.MenuVisible { t.Fatal("menu not shown") } + yTop := e.MenuRect.Y // above line 5 - // Scroll 10 lines: line 5 is 4 lines ABOVE the viewport top. + // Scroll 10 lines: line 5 is 5 lines ABOVE the viewport top. Both ends + // of the selection stay inside the shaped window (small file = whole + // buffer) so the menu tracks: the preferred above-placement overflows + // (the line is off-screen top) and the menu flips below the line, which + // lands just under the window top (lineTop + lh + handleDropDp + 8). TheState.ScrollOffset = ui.Dp(10 * lh) EditorLayout(ui.Dp(1000), ui.Dp(2000), false) if !e.MenuVisible { - t.Fatal("menu hidden, but small-file layout can still track it (pinned)") + t.Fatal("menu hidden, but the selection is still in the shaped window") + } + lineTop := ui.Dp(42) + ui.Dp(5*lh) - ui.Dp(10*lh) // line 5 after the scroll + want := lineTop + ui.Dp(lh) + ui.Dp(handleDropDp) + 8 + if got := e.MenuRect.Y; !dpeq(got, want) { + t.Fatalf("menu Y = %v, want %v (tracked below the off-screen-top line, near the window top)", got, want) + } + _ = yTop + + // Scroll far enough that even the below-placement overflows: the menu + // pins to the window top. + TheState.ScrollOffset = ui.Dp(60 * lh) + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + if !e.MenuVisible { + t.Fatal("menu hidden while the selection is still in the shaped window") } if got := e.MenuRect.Y; !dpeq(got, 8) { - t.Fatalf("menu Y = %v, want 8 (pinned to the window top when the text is above the viewport)", got) + t.Fatalf("menu Y = %v, want 8 (pinned to the window top)", got) } } @@ -151,9 +171,133 @@ func TestSelectionMenu_FlipsBelowOnFirstLine(t *testing.T) { t.Fatal("menu not shown") } // Line 0 top = reg.Y = 42; above would be 42-52-8 = -18 < 8, so the menu - // flips below: 42 + lh + 8. - wantY := ui.Dp(42) + EffectiveLineHeight() + 8 + // flips below the selection HANDLES: line bottom + handleDropDp + 8. + wantY := ui.Dp(42) + EffectiveLineHeight() + ui.Dp(handleDropDp) + 8 if !dpeq(e.MenuRect.Y, wantY) { - t.Fatalf("menu Y = %v, want %v (flipped below the first line)", e.MenuRect.Y, wantY) + t.Fatalf("menu Y = %v, want %v (flipped below the first line, clear of the handles)", e.MenuRect.Y, wantY) + } +} + +// TestSelectionMenu_AnchorsToStableEnd is a regression test for the menu +// floating inside a tall selection: the menu anchors to the selection's +// STABLE end (SelectionStart, the end that does not move while the END +// handle is dragged), so it must sit above the selection's TOP line even for +// a multi-line selection, and must not move when the end handle is dragged +// to a lower line. +func TestSelectionMenu_AnchorsToStableEnd(t *testing.T) { + lh := menuTrackState(t) + + // 8 lines; selection from line 2 (stable start) to line 5 (moving end), + // so the menu has room ABOVE the top line (line 0 would flip below). + var buf strings.Builder + for i := 0; i < 8; i++ { + buf.WriteString("aaa\n") + } + TheState.Editor.Buffer = buf.String() + SetSelection(8, 18) // lines 2..4 (anchor at line 2's first byte) + + gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()} + const ascent float64 = 14 + for i := 0; i < 8; i++ { + for c := 0; c < 4; c++ { + gl.ByteOffsets = append(gl.ByteOffsets, i*4+c) + gl.X = append(gl.X, ui.Dp(10+10*c)) + gl.Y = append(gl.Y, ui.Dp(ascent+float64(i)*lh)) + gl.Advance = append(gl.Advance, 10) + } + } + TheState.Editor.GlyphLayout = gl + TheState.LastLineY = ui.Dp(8 * lh) + TheState.Editor.IMEWindowStartByte = 0 + TheState.ScrollOffset = 0 + + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + showSelectionMenu() + e := &TheState.Editor + if !e.MenuVisible { + t.Fatal("menu not shown") + } + // Above line 2 (the stable start), NOT above line 4 (the moving end). + wantY := ui.Dp(42) + ui.Dp(2*lh) - menuH - 8 + if !dpeq(e.MenuRect.Y, wantY) { + t.Fatalf("menu Y = %v, want %v (above the selection's TOP line)", e.MenuRect.Y, wantY) + } + + // Drag the END handle down to line 7: the menu must stay put (still + // anchored to the stable start at line 2), not follow the end down. + SetSelection(8, 30) // lines 2..7 + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + if !e.MenuVisible { + t.Fatal("menu hidden while the stable end is in view") + } + if got := e.MenuRect.Y; !dpeq(got, wantY) { + t.Fatalf("menu Y after end drag = %v, want %v (must not follow the moving end)", got, wantY) + } +} + +// TestSelectionMenu_KeptVisibleWhileSelectionPartiallyOffScreen is a +// regression test for the menu disappearing permanently when the selection +// is extended to the top line while the menu was above it: with both ends +// outside the shaped window but the selection still intersecting the window, +// the menu keeps its previous position (clamped to the window) instead of +// hiding. (Small-file state: the whole buffer is the window, so this is +// emulated by a large file whose window is only a slice of the buffer.) +func TestSelectionMenu_KeptVisibleWhileSelectionPartiallyOffScreen(t *testing.T) { + lh := menuTrackState(t) + + // Large buffer: 1000 "aa\n" lines. The shaped window is lines 2..40 + // (bytes [6, 120)) — a slice in the middle of the buffer. + var buf strings.Builder + for i := 0; i < 1000; i++ { + buf.WriteString("aa\n") + } + TheState.Editor.Buffer = buf.String() + + // Selection straddling the window: start byte 3 (line 1, ABOVE the + // window), end byte 180 (line 59, BELOW the window). + SetSelection(3, 180) + TheState.Editor.IMEWindowStartByte = 6 + TheState.Editor.IMEWindowText = buf.String()[6:120] + + gl := ui.GlyphLayout{LineHeight: EffectiveLineHeight()} + for i := 2; i < 40; i++ { + for c := 0; c < 2; c++ { + gl.ByteOffsets = append(gl.ByteOffsets, i*3+c) + gl.X = append(gl.X, ui.Dp(10+10*c)) + gl.Y = append(gl.Y, ui.Dp(14+float64(i-2)*lh)) + gl.Advance = append(gl.Advance, 10) + } + } + TheState.Editor.GlyphLayout = gl + TheState.Editor.MenuItems = []ui.MenuItem{{Label: "Copy"}} + // Establish window scale/region (positionSelectionMenu reads + // PixelWidth/PixelHeight via TheState.scale to clamp). + EditorLayout(ui.Dp(1000), ui.Dp(2000), false) + TheState.scale = 1 + TheState.PixelWidth = 1000 + TheState.PixelHeight = 2000 + // EditorLayout re-shaped the small-file window; restore the hand-built + // slice window for the both-ends-!ok scenario. + TheState.Editor.IMEWindowStartByte = 6 + TheState.Editor.IMEWindowText = buf.String()[6:120] + TheState.Editor.GlyphLayout = gl + e := &TheState.Editor + + // Give the menu a previous (now stale, off-screen-top) position and + // check positionSelectionMenu keeps it, clamped, instead of reporting + // false (hide). + e.MenuRect = ui.Region{X: 100, Y: ui.Dp(-30), W: menuItemW, H: menuH} + if !positionSelectionMenu(e) { + t.Fatal("positionSelectionMenu reported hide while the selection still intersects the visible window") + } + if e.MenuRect.Y < 8 { + t.Fatalf("menu Y = %v, want clamped to >= 8 (kept at previous position, clamped)", e.MenuRect.Y) + } + + // And it must still hide when the selection is entirely above the window. + SetSelection(0, 3) // line 0: entirely above window start 6 + TheState.Editor.GlyphLayout = gl + if positionSelectionMenu(e) { + t.Fatal("positionSelectionMenu reported visible for a selection entirely outside the window") } } diff --git a/internal/editor/state.go b/internal/editor/state.go index c4a8435..6da0ebb 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -637,6 +637,14 @@ func deleteRange(start, end int) { const ( menuItemW = ui.Dp(56) // width of one selection-menu button menuH = ui.Dp(52) // selection-menu panel height + // handleDropDp is how far below a visual line's bottom edge the selection + // handle's GRAB BOX extends: 10dp (handle radius, line bottom to handle + // centre) + 24dp (half the 48dp grab box). Keep in sync with the + // renderer's registerDrag geometry (internal/ui/render.go). A menu placed + // below a single-line selection sits this far below the line's bottom + // edge so it does not overlap the handles' grab boxes — the menu is drawn + // topmost (z-order) and would steal the handles' drags. + handleDropDp = 34 ) // isWordRune reports whether a rune is part of a selectable word (letters, @@ -763,30 +771,79 @@ func hideSelectionMenu() { e.MenuRect = ui.Region{} } -// positionSelectionMenu places the copy/cut/paste menu above the visual line -// containing the anchor (selection end or caret) — like the native Android -// selection toolbar — falling back to below when there is no room above. -// Above matters: the selection handles hang off the line's bottom edge and -// their 48dp grab boxes would sit under a below-placed menu; the menu is -// drawn last (top of the z-order) and would steal those touches. It -// is called from showSelectionMenu and from EditorLayout on every frame -// while the menu is visible, so the menu tracks the selected text when the -// user scrolls: the menu is anchored to the TEXT (an absolute buffer offset +// positionSelectionMenu places the copy/cut/paste menu relative to the +// selection, mimicking the native Android selection toolbar: +// +// - Preferred: ABOVE the selection's top line. The menu is anchored to the +// STABLE end of the selection — SelectionStart, the end that does not +// move while the user drags the END handle — so the menu does not chase +// the moving handle and does not float inside a tall selection (anchoring +// to the end would drag the menu through the selected text when the end +// handle is dragged down). While the START handle is dragged, the start +// moves and the end is fixed, so it anchors to the end. With no +// selection (caret) it anchors to the caret. +// - Fallback: when there is no room above (the selection starts at the top +// of the window), BELOW the selection. For a single-line selection the +// menu sits below the selection HANDLES (they hang off the line's bottom +// edge): the menu is drawn last (top of the z-order) and would steal the +// handles' drags wherever it overlaps their grab boxes. For a multi-line +// selection it sits 8dp below the selection's bottom edge, like the +// native toolbar. +// +// The menu must not vanish while the selection is still on screen: when both +// ends of the selection are outside the shaped window but some part of the +// selection is still visible, the menu keeps its previous position clamped +// to the window. It reports false (and the caller hides it) only when the +// selection has left the window entirely. Re-anchoring (double-tap) re-shows +// it. It is called from showSelectionMenu and from EditorLayout on every +// frame while the menu is visible, so the menu tracks the selected text when +// the user scrolls: it is anchored to the TEXT (an absolute buffer offset // mapped through the live screen geometry), not to the screen position where -// it was first shown. It reports false when the anchor is outside the shaped -// window (scrolled away): there is no layout to anchor to, and the caller -// hides the menu. Re-anchoring the selection (double-tap) re-shows it. +// it was first shown. func positionSelectionMenu(e *EditorState) bool { if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 || len(e.MenuItems) == 0 { return false } - anchor := e.CursorPosition - if e.SelectionEnd > anchor { - anchor = e.SelectionEnd + anchor, other := e.SelectionStart, e.SelectionEnd + if !selActive() { + anchor, other = e.CursorPosition, e.CursorPosition + } else if e.SelDragging && e.SelDragWhich == 0 { + anchor, other = e.SelectionEnd, e.SelectionStart } glyphX, lineTop, ok := bytePosToScreenXY(anchor) if !ok { - return false + glyphX, lineTop, ok = bytePosToScreenXY(other) + } + if !ok { + // Both ends are outside the shaped window. Hide the menu only when no + // part of the selection is visible; otherwise keep it at its previous + // position, clamped inside the window. + base := glyphBase() + if e.SelectionEnd <= base || e.SelectionStart >= base+len(e.IMEWindowText) { + return false + } + var winW, winH float64 + if TheState.scale > 0 { + winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale)) + winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale)) + } + if winW > 0 { + if e.MenuRect.X < 8 { + e.MenuRect.X = 8 + } + if float64(e.MenuRect.X+e.MenuRect.W) > winW-8 { + e.MenuRect.X = ui.Dp(winW) - 8 - e.MenuRect.W + } + } + if winH > 0 { + if e.MenuRect.Y < 8 { + e.MenuRect.Y = 8 + } + if float64(e.MenuRect.Y+e.MenuRect.H) > winH-8 { + e.MenuRect.Y = ui.Dp(winH) - 8 - e.MenuRect.H + } + } + return true } var winW, winH float64 if TheState.scale > 0 { @@ -801,15 +858,37 @@ func positionSelectionMenu(e *EditorState) bool { if winW > 0 && mx+float64(menuW) > winW-8 { mx = winW - float64(menuW) - 8 } - // Prefer ABOVE the line, like the native Android selection toolbar. - // Placing it below would sit over the selection handles' grab region - // (the handles hang off the line's bottom edge), and the menu is drawn - // last (top of the z-order) so it would steal the touches meant for the - // handles. Above keeps the handles fully grabbable. Flip below only when - // there is no room above. + lh := float64(EffectiveLineHeight()) + // Multi-line iff the selection spans more than one logical line. + // (Testing whether the opposite end sits on a LOWER visual line does not + // work: a selection ending at a line's trailing newline maps to the NEXT + // line, so single-line selections would read as multi-line.) + multiLine := false + if cb := e.ChunkedBuffer; cb != nil { + if li := cb.LineIndex; li != nil && other > anchor { + multiLine = li.FindLogicalLineForByteOffset(anchor) != + li.FindLogicalLineForByteOffset(other-1) + } + } + // Selection bottom: the bottom of the visual line containing the opposite + // end (the anchor line itself for a single-line selection). + selBottom := lineTop + lh + if multiLine { + if _, ot, ok2 := bytePosToScreenXY(other); ok2 && ot > lineTop+0.5 { + selBottom = ot + lh + } + } + // Prefer ABOVE the selection's top line (see the doc above). my := lineTop - float64(menuH) - 8 if my < 8 { - my = lineTop + float64(EffectiveLineHeight()) + 8 // flip below the line + // No room above: place below. A single-line selection needs the menu + // clear of its handles (see the doc above); a multi-line selection + // follows the native toolbar (8dp below the selection's bottom edge). + gap := 8.0 + if !multiLine { + gap = float64(handleDropDp) + 8 + } + my = selBottom + gap if winH > 0 && my+float64(menuH) > winH-8 { my = winH - float64(menuH) - 8 // clamp to window bottom } @@ -1041,11 +1120,26 @@ 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 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 pos < e.SelectionStart { pos = e.SelectionStart } @@ -1066,6 +1160,36 @@ func selDragMove(which int, x, y ui.Dp) { } } +// 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 +// (handleAt): the line of the first glyph whose byte offset is at or past +// the offset, or the last line when the offset is past the last glyph. +func visualLineOfByte(winByte int) (int, bool) { + layout := TheState.Editor.GlyphLayout + if len(layout.ByteOffsets) == 0 || len(layout.Y) == 0 { + return 0, false + } + lineHeight := float64(EffectiveLineHeight()) + minY := 1e9 + for _, yVal := range layout.Y { + if float64(yVal) < minY { + minY = float64(yVal) + } + } + idx := sort.Search(len(layout.ByteOffsets), func(i int) bool { + return layout.ByteOffsets[i] >= winByte + }) + if idx == len(layout.ByteOffsets) { + idx = len(layout.ByteOffsets) - 1 + } + lineIdx := int((float64(layout.Y[idx])-minY)/lineHeight + 0.5) // round to nearest line + if lineIdx < 0 { + lineIdx = 0 + } + return lineIdx, true +} + // HandleMenuTap hit-tests a tap on the menu panel and runs the tapped item. func HandleMenuTap(x, y ui.Dp) { e := &TheState.Editor @@ -2087,7 +2211,6 @@ func textPosFromLocalPoint(x, y float64) (int, bool) { return 0, false } - base := glyphBase() lineHeight := float64(EffectiveLineHeight()) // 1. Identify the intended line index based on y @@ -2096,6 +2219,23 @@ func textPosFromLocalPoint(x, y float64) (int, bool) { // So y is the position in the *content*. visualLine := int(y / lineHeight) + return textPosOnLineAtX(visualLine, x) +} + +// textPosOnLineAtX maps a text-local x (Dp, same convention as +// textPosFromLocalPoint) on the given visual line to an absolute byte +// offset. Out-of-range lines clamp to the nearest non-empty line, exactly +// like the y-based selection in textPosFromLocalPoint. ok=false when the +// layout is empty or the (clamped) line has no glyphs. +func textPosOnLineAtX(visualLine int, x float64) (int, bool) { + layout := TheState.Editor.GlyphLayout + if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 { + return 0, false + } + + base := glyphBase() + lineHeight := float64(EffectiveLineHeight()) + // Group glyphs by their Y-baseline type lineGroup struct { y float64 @@ -2121,7 +2261,7 @@ func textPosFromLocalPoint(x, y float64) (int, bool) { } // Now group by baseline - groups = []lineGroup{} // Reset groups + groups = []lineGroup{} for i, yVal := range layout.Y { yFloat := float64(yVal) lineIdx := int((yFloat-minY)/lineHeight + 0.5) // round to nearest line diff --git a/internal/editor/touch_selection_test.go b/internal/editor/touch_selection_test.go index 76c0b4f..6465f9d 100644 --- a/internal/editor/touch_selection_test.go +++ b/internal/editor/touch_selection_test.go @@ -366,3 +366,89 @@ func TestSelectionEdit_CutThenTypeReplaces(t *testing.T) { t.Errorf("buffer = %q, want %q", got, "goodbye world") } } + +// touchSelState2Lines sets up the same geometry as touchSelState but with a +// second line beneath the first: "hello world\nsecond line\n". Line 0 glyphs +// (bytes 0..10) sit at Y=0; line 1 glyphs (bytes 12..22) at Y=lineHeight. +// The 48dp handle grab box of a line-0 handle straddles into line 1's row, +// which is what the line-projection regression tests rely on. +func touchSelState2Lines() { + content := "hello world\nsecond line\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) // EditorLineHeight at the default font scale + bo := []int{} + xs := []ui.Dp{} + s := []ui.Dp{} + ad := []ui.Dp{} + for i := 0; i < 11; i++ { // line 0: bytes 0..10 + bo = append(bo, i) + xs = append(xs, ui.Dp(10*i)) + s = append(s, 0) + ad = append(ad, 10) + } + for j := 0; j < 11; j++ { // line 1: bytes 12..22 + bo = append(bo, 12+j) + xs = append(xs, ui.Dp(10*j)) + s = append(s, lh) + ad = append(ad, 10) + } + TheState.Editor.GlyphLayout = ui.GlyphLayout{ + LineHeight: lh, + ByteOffsets: bo, + X: xs, + Y: s, + Advance: ad, + } +} + +// 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. +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. + 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 +} + +// 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) { + 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) +} + +func checkInitialSelection(t *testing.T) { + t.Helper() + s := TheState.Editor + if s.SelectionStart != 0 || s.SelectionEnd != 5 { + t.Fatalf("precondition: selection = [%d,%d), want [0,5)", s.SelectionStart, s.SelectionEnd) + } +} diff --git a/internal/ui/render.go b/internal/ui/render.go index 7d0048c..7d98489 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -124,8 +124,18 @@ type Renderer struct { // (-1 = none), set by CheckGestures and read by drawWrappedText to // enlarge the grabbed handle, as the framework does while dragging. selDraggingWhich int + // gestureExclusions holds the selection-handle grab boxes (view-local + // px, [x0, y0, x1, y1]) collected by drawWrappedText for the current + // frame. On Android the main loop forwards them to + // View.setSystemGestureExclusionRects (API 29+) so drags starting on an + // edge handle are not stolen by the system back gesture. + gestureExclusions [][4]int } +// GestureExclusions returns the handle grab boxes collected for the last +// frame (see gestureExclusions). +func (r *Renderer) GestureExclusions() [][4]int { return r.gestureExclusions } + // New creates a new Renderer. func New(th Theme, shp *text.Shaper) *Renderer { r := &Renderer{ @@ -664,6 +674,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w if str == "" { return } + r.gestureExclusions = nil // rebuilt from this frame's handle boxes // Fixed line height based on font size, not glyph metrics. lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale) @@ -840,6 +851,30 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w const grab = Dp(24) // 48dp box minx, miny := int(r.toPx(hx-grab)), int(r.toPx(cy-grab)) maxx, maxy := int(r.toPx(hx+grab)), int(r.toPx(cy+grab)) + // System-gesture exclusion (Android API 29+): a drag that STARTS + // inside ~20dp of the screen edge can be taken over by the system + // back gesture (predictive back) — it cancels the handle drag and + // navigates the app away. Excluding the grab boxes (clipped to the + // editor region, which is what is actually grabbable) keeps edge + // handles, e.g. the left handle of a line-start selection, usable. + // The main loop forwards these rects to + // View.setSystemGestureExclusionRects (see SetGestureExclusions). + ex0, ey0, ex1, ey1 := minx, miny, maxx, maxy + if ex0 < int(r.toPx(reg.X)) { + ex0 = int(r.toPx(reg.X)) + } + if ey0 < int(r.toPx(reg.Y)) { + ey0 = int(r.toPx(reg.Y)) + } + if ex1 > int(r.toPx(reg.X+reg.W)) { + ex1 = int(r.toPx(reg.X + reg.W)) + } + if ey1 > int(r.toPx(reg.Y+reg.H)) { + ey1 = int(r.toPx(reg.Y + reg.H)) + } + if ex1 > ex0 && ey1 > ey0 { + r.gestureExclusions = append(r.gestureExclusions, [4]int{ex0, ey0, ex1, ey1}) + } hc := clip.Rect{ Min: image.Point{X: minx, Y: miny}, Max: image.Point{X: maxx, Y: maxy}, @@ -850,10 +885,14 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w if selStart >= 0 && selEnd > selStart { sx, sy := handleAt(selStart) ex, ey := handleAt(selEnd) - registerDrag(&r.selDragStart, sx, sy) - r.selDragsOn[0] = true - registerDrag(&r.selDragEnd, ex, ey) - r.selDragsOn[1] = true + // Body FIRST, handles after: Gio routes a touch to the TOPMOST op + // whose clip contains the point, and a drag only grabs after it + // received the PRESS. The handle boxes reach up into the text line + // (their centres hang below the line) and the body box spans the + // line, so wherever they overlap the later-registered op wins. The + // handles are the more specific target and must win the overlap; + // registering the body last made line-start handles effectively + // ungrabbable (the body ate the presses). // Body: bounding box of the selected glyphs (only for 2+ glyphs; a // single-glyph selection is already covered by its two handles). var bx0, by0, bx1, by1 Dp @@ -898,6 +937,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w bc.Pop() r.selDragsOn[2] = true } + registerDrag(&r.selDragStart, sx, sy) + r.selDragsOn[0] = true + registerDrag(&r.selDragEnd, ex, ey) + r.selDragsOn[1] = true r.drawHandle(gtx, sx, sy, lineH, r.selDraggingWhich == 0) r.drawHandle(gtx, ex, ey, lineH, r.selDraggingWhich == 1) } else { diff --git a/scripts/build_emu.sh b/scripts/build_emu.sh index 515f55a..1fc38b2 100755 --- a/scripts/build_emu.sh +++ b/scripts/build_emu.sh @@ -260,6 +260,57 @@ listener = ''' ''' (base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener) +# 1b. New class: a Runnable that applies system-gesture exclusion rects on +# the UI thread. The Go frame loop runs on its OWN thread (not the UI +# thread), so View.setSystemGestureExclusionRects must be posted: it is the +# relayout that reports the rects to the window manager, which owns the edge +# back-gesture zone. Without the post the rects are set on the view but never +# reach the window manager, and the back gesture still steals edge drags. +padexcl = ''' +.class public Lorg/gioui/PadExcl; +.super Ljava/lang/Object; +.source "PadExcl.java" + +# interfaces +.implements Ljava/lang/Runnable; + + +# instance fields +.field public final mView:Landroid/view/View; + +.field public final mList:Ljava/util/List; + + +# direct methods +.method public constructor (Landroid/view/View;Ljava/util/List;)V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + iput-object p1, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View; + + iput-object p2, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List; + + return-void +.end method + + +# virtual methods +.method public run()V + .locals 2 + + iget-object v0, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View; + + iget-object v1, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List; + + invoke-virtual {v0, v1}, Landroid/view/View;->setSystemGestureExclusionRects(Ljava/util/List;)V + + return-void +.end method + +''' +(base / "smali" / "org" / "gioui" / "PadExcl.smali").write_text(padexcl) + # 2. Wire it up in GioActivity.onCreate (and opt out of decor auto-fit). p = base / "smali" / "org" / "gioui" / "GioActivity.smali" t = p.read_text() diff --git a/scripts/build_phone.sh b/scripts/build_phone.sh index 47d44bc..d9be45f 100755 --- a/scripts/build_phone.sh +++ b/scripts/build_phone.sh @@ -253,6 +253,57 @@ listener = ''' ''' (base / "smali" / "org" / "gioui" / "PadInsetsListener.smali").write_text(listener) +# 1b. New class: a Runnable that applies system-gesture exclusion rects on +# the UI thread. The Go frame loop runs on its OWN thread (not the UI +# thread), so View.setSystemGestureExclusionRects must be posted: it is the +# relayout that reports the rects to the window manager, which owns the edge +# back-gesture zone. Without the post the rects are set on the view but never +# reach the window manager, and the back gesture still steals edge drags. +padexcl = ''' +.class public Lorg/gioui/PadExcl; +.super Ljava/lang/Object; +.source "PadExcl.java" + +# interfaces +.implements Ljava/lang/Runnable; + + +# instance fields +.field public final mView:Landroid/view/View; + +.field public final mList:Ljava/util/List; + + +# direct methods +.method public constructor (Landroid/view/View;Ljava/util/List;)V + .locals 0 + + invoke-direct {p0}, Ljava/lang/Object;->()V + + iput-object p1, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View; + + iput-object p2, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List; + + return-void +.end method + + +# virtual methods +.method public run()V + .locals 2 + + iget-object v0, p0, Lorg/gioui/PadExcl;->mView:Landroid/view/View; + + iget-object v1, p0, Lorg/gioui/PadExcl;->mList:Ljava/util/List; + + invoke-virtual {v0, v1}, Landroid/view/View;->setSystemGestureExclusionRects(Ljava/util/List;)V + + return-void +.end method + +''' +(base / "smali" / "org" / "gioui" / "PadExcl.smali").write_text(padexcl) + # 2. Wire it up in GioActivity.onCreate (and opt out of decor auto-fit). p = base / "smali" / "org" / "gioui" / "GioActivity.smali" t = p.read_text()