Fix selection-handle drags, menu anchoring, and left-edge back-gesture theft

Three user-reported selection bugs, one root cause each:

1. Start handle ungrabbable at line start. Two interacting causes:
   a) The 48dp grab box straddles two visual lines; a finger in the
      lower half mapped (by y-to-line) to the neighbouring line, whose
      byte past the other handle clamped to a zero-length selection ->
      cleared on the first drag event. The cleared selection
      un-registered the drag op, so the router silently stopped
      delivering drag events (the observed 'stream cutoff'). Fix:
      handle drags now project the finger's x onto the anchor's own
      visual line (visualLineOfByte + textPosOnLineAtX); the anchor
      never crosses lines during a handle drag.
   b) A horizontal flick from the line-start handle (screen x~26px)
      started the system back gesture, which cancelled the touch
      stream. Fix: report the handle grab rects as system gesture
      exclusion rects (setSystemGestureExclusionRects, API 29+),
      marshalled to the UI thread via a PadExcl smali Runnable
      (generated identically by build_emu.sh/build_phone.sh).

2. End-handle drag downward made the menu chase the finger and cover
   the selection. Fix: the menu anchors to the STABLE end of the
   selection (the end not being dragged), so it stays parked by the
   selection start, clear of the finger and the highlighted text.

3. Menu above the selection vanished permanently when the selection
   was extended onto the top line. Fix: off-window anchors no longer
   hide the menu while any part of the selection is visible (keep-last
   rect, clamped); hiding happens only for fully off-window selections.

Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
This commit is contained in:
Greg Pomerantz 2026-08-18 13:09:29 -04:00
parent 2b7c9cd3eb
commit 275a78efaa
12 changed files with 748 additions and 37 deletions

View File

@ -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

View File

@ -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) {}

View File

@ -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, "<init>", "(IIII)V");
jclass listCls = (*env)->FindClass(env, "java/util/ArrayList");
jmethodID listInit = (*env)->GetMethodID(env, listCls, "<init>", "()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, "<init>", "(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);
}

View File

@ -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);

View File

@ -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

View File

@ -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).

View File

@ -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")
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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 {

View File

@ -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 <init>(Landroid/view/View;Ljava/util/List;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;-><init>()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()

View File

@ -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 <init>(Landroid/view/View;Ljava/util/List;)V
.locals 0
invoke-direct {p0}, Ljava/lang/Object;-><init>()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()