Touch selection (v1): long-press/double-tap word selection, drag handles, floating copy/cut/paste menu

Implement the Android-native touch selection model, verified on-device:
- long-press selects the word under the finger (blank -> caret + paste-only
  menu); double-tap selects the word; drag handles resize the selection,
  drag the highlighted body to move it; floating menu offers copy/cut/paste
  (selection) or paste (bare caret), closing on any item tap.
- Renderer reports finger positions (app-local Dp) as tap/double-tap/
  long-press/selection-drag events; the logic goroutine owns all geometry
  (EditorRegion, menu rect, hit-testing, handles) and the renderer only
  draws the frame snapshot.
- Long press: 400 ms still-press on the editor, cancelled by movement
  (non-grabbing raw pointer probe) or by a scroll/handle grab. The main
  loop keeps invalidating while a press is pending (Gio renders on demand;
  a stationary finger produces no frames).
- Clipboard crosses the goroutine boundary via buffered channels
  (clipboardSetChan/pasteReqChan logic->main, pasteChan main->logic);
  main executes the Gio ops and, on Android, invalidates after ReadCmd
  because a queued transfer.DataEvent schedules no frame of its own.

Renderer fixes found while validating on-device:
- clickReg was stored by value in a map; range yielded copies so per-frame
  press bookkeeping (long-press state) was silently discarded. Now pointers.
- On Android a tap's press+release arrive in the same frame and
  gesture.Click/Drag return one event per Update call; without draining
  each gesture's queue every frame the release was lost on an idle window
  and every menu tap was swallowed (needed a second tap to 'rescue' it).
  Click and drag loops now drain to exhaustion (scroll already does).
- pointer.Filter queries must name Kinds: a zero-kinds filter matches
  nothing (the press-probe query was dead).
- Menu.Draw offsets items by the menu origin; the clippable drawElement
  branch registers SelDrag (handles now draw for the TextField).

Tests: internal/editor/touch_selection_test.go (word range, long-press,
double-tap, tap/menu guards, handle drags, menu actions, selection edits)
and internal/test/e2e/touch_selection_e2e_test.go; full suite green under
-race. Docs: spec.md §2.2 + §7, architecture.md §6.3a, development_plan.md
Phases 8-9.
This commit is contained in:
Greg Pomerantz 2026-08-17 08:57:55 -04:00
parent ec11abf8f1
commit 2a16c0017c
10 changed files with 1720 additions and 56 deletions

View File

@ -2,15 +2,19 @@ package main
import (
"flag"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gioui.org/app"
"gioui.org/font/gofont"
"gioui.org/io/clipboard"
"gioui.org/io/key"
"gioui.org/io/transfer"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
@ -97,6 +101,14 @@ func run(w *app.Window) error {
var searchEditor widget.Editor
renderer.RegisterGioEditor("search_bar", &searchEditor)
// Clipboard plumbing (architecture.md §6.3): the logic goroutine only
// REQUESTS clipboard operations through channels (copy/cut write, paste
// read); the main goroutine executes the Gio ops during a frame and
// forwards the read result back. clipTag tags the read so its
// transfer.DataEvent can be matched. A nil clipboard (e.g. a headless
// test) simply never completes a read.
var clipTag = struct{}{}
// frame is the frame-receiver-stored handoff (architecture.md §2.2/§9):
// the ONLY data the main goroutine reads from the logic side.
var mu sync.Mutex
@ -134,6 +146,40 @@ func run(w *app.Window) error {
}
gtx := app.NewContext(&ops, e)
newScale := gtx.Metric.PxPerDp
// Clipboard: forward a read result from an earlier frame to the
// logic goroutine (one DataEvent per ReadCmd).
for {
evt, ok := gtx.Event(transfer.TargetFilter{Target: clipTag, Type: "application/text"})
if !ok {
break
}
if de, ok := evt.(transfer.DataEvent); ok {
body := de.Open()
data, rerr := io.ReadAll(body)
body.Close()
if rerr == nil {
logic.PasteChan() <- string(data)
}
}
}
// Clipboard: a copy/cut write requested by the logic goroutine.
select {
case t := <-logic.ClipboardSetChan():
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(t))})
default:
}
// Clipboard: a paste request (one ReadCmd per request; the result
// arrives as a DataEvent on a later frame). On Android the read is
// synchronous and the resulting DataEvent is queued during this
// frame's op flush — but a queued DataEvent schedules no wakeup of
// its own, so invalidate to guarantee a follow-up frame in which
// the loop above can consume it.
select {
case <-logic.PasteReqChan():
gtx.Execute(clipboard.ReadCmd{Tag: clipTag})
w.Invalidate()
default:
}
// Read ONLY the frame-receiver-stored snapshot; the main goroutine
// never touches logic State (architecture.md §1).
mu.Lock()
@ -148,6 +194,12 @@ func run(w *app.Window) error {
newQuery := searchEditor.Text()
sendQuery := newQuery != frame.Query
events := renderer.CheckGestures(e.Source, gtx.Metric)
// Keep frames flowing while a long press is pending: a stationary
// finger generates no pointer events, so without this the window
// would sleep and the long-press threshold would never be reached.
if renderer.PendingLongPress() {
w.Invalidate()
}
// Gather key events
focusedID := frame.FocusedElementID

View File

@ -270,6 +270,63 @@ Only the visible byte range is shaped and drawn each frame:
before the glyphs; the IME `SelectionCmd` push dedups on the
(selectionStart, caret) pair.
### 6.3a Touch selection (v1)
- **Division of labor: the renderer reports finger positions, the logic owns
all geometry.** Touch input is delivered to the logic goroutine as
`ui.Point` (tap), `ui.DoubleTapPoint`, `ui.LongPressPoint`, and selection
drag events (which handle + app-local Dp position). The logic converts them
to text coordinates using the `EditorRegion` stored on `State` (set each
layout frame) plus the scroll offset, hit-tests the floating-menu items
itself, and owns the menu rect, highlight range, and handle positions.
The renderer only draws what the `Frame` snapshot says (menu, handles,
highlight) and registers the input regions.
- **Menu taps are logic-decided.** The menu panel is one `Tap` interaction
over its whole rect; the editor's tap handler ignores any tap inside a
visible menu (`pointInMenu` guard) so both handlers can coexist regardless
of dispatch order. Item identity comes from the tap's X within the panel.
- **Long press needs frames to elapse.** Gio renders on demand; a stationary
finger produces no pointer events and therefore no frames, so the 400 ms
threshold could never be checked. The main loop polls
`Renderer.PendingLongPress()` and keeps invalidating the window while a
press is held still on the editor. A non-grabbing raw pointer probe
(plain `event.Op` tag on the editor region) observes the press's motion and
cancels the pending long press on movement; a scroll or handle drag grabs
the pointer (`pointer.GrabCmd`), which cancels it via `pointer.Cancel`.
- **A pointer filter with zero `Kinds` matches nothing.**
`pointer.Filter.Matches` tests `e.Kind & f.Kinds == e.Kind`, so a query
without `Kinds` silently receives no pointer events — probe queries must
name the kinds they want.
- **One event per Update is a trap on Android.** A tap's down+up routinely
arrive in a *single* frame, and `gesture.Click`/`gesture.Drag` return at
most one event per `Update` call. If the renderer processed only one event
per gesture per frame, the release would sit in the queue until the next
redraw — which on an idle window may never come — and the tap is swallowed
(this is why menu taps initially required a second tap to "rescue" the
first). The renderer therefore drains each click/drag gesture's queue to
exhaustion every frame; `gesture.Scroll` already drains internally.
- **Per-frame gesture bookkeeping must survive the frame.** Click-registry
state (press time/position, long-press-fired flag) is kept in structs held
**by pointer** in a map; `range` over a value-type map yields copies and
silently discards the mutations.
- **Clipboard crosses the goroutine boundary via channels; the ops run on
the main/Gio frame path.** Logic→main: `clipboardSetChan` (string to write)
and `pasteReqChan` (token). Main→logic: `pasteChan` (string). All three are
buffered (16) so a harness with no main loop can never block the logic
goroutine. Main executes `clipboard.WriteCmd` / `clipboard.ReadCmd` during
a frame and forwards read results back on `pasteChan` from the
`transfer.DataEvent`.
- **Android clipboard reads need an explicit invalidation.** Gio v0.10 on
Android answers `ReadCmd` synchronously during the op flush by queueing a
`transfer.DataEvent`; a queued DataEvent schedules **no** frame wakeup of
its own. Main therefore invalidates the window after each read so a
follow-up frame exists in which the DataEvent is consumed.
- **The menu closes on any item tap.** Copy keeps the selection (only the
menu disappears); cut removes it (via `ClearSelection`, which also hides
the menu and cancels drag bookkeeping); paste closes the menu immediately
even though the actual insert happens on a later frame when the clipboard
content arrives.
### 6.4 IME (Android soft keyboard)
- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the

View File

@ -1,7 +1,8 @@
# Development Plan: reach a lean, usable Android text editor
Status: v7, 2026-08-16 (Phases 03 + doc reorg + Phase 6 scroll-perf/clamping
verification + tap-to-position-cursor fix + selection + real-file e2e). Written
Status: v8, 2026-08-17 (Phases 03 + doc reorg + Phase 6 scroll-perf/clamping
verification + tap-to-position-cursor fix + selection + real-file e2e +
Android arrow/shift workaround + touch selection). Written
against the **live** repo `/home/gmp/pad`. v1 (the widget-rebuild plan) is
superseded — see §12 for why. Doc reorganization (2026-08-16): the over-detailed
docs (`*_implementation_plan.md`, `touch.md`, `element_model.md`,
@ -44,6 +45,18 @@ Verified end-to-end on the emulator: plain arrows move the caret,
shift+arrow shows a highlight, typing replaces the selection. Remaining:
real-device swipe/autocorrect sign-off (the emulator's AOSP/Gboard keyboard
is a proxy).
Phase 9 (2026-08-17) added **touch selection** — the Android-native selection
model: long-press selects the word under the finger (blank → caret + paste-only
menu), double-tap selects the word, drag handles resize the selection, drag the
highlighted body moves it, and a floating copy/cut/paste menu is hit-tested in
logic and drawn by the renderer. All flows verified on-device, including the
full copy→paste and cut cycles through the real Android clipboard. Debugging
this surfaced two renderer bugs worth knowing about: (1) a map of click-registry
**values** (not pointers) silently discarded per-frame mutations, so the
long-press never fired; (2) on Android a tap's press and release arrive in the
**same frame**, and `gesture.Click`/`gesture.Drag` return one event per Update
call, so without draining each gesture's queue every frame the release was lost
on an idle window and every menu tap was swallowed.
## 1. Decision summary (updated)
@ -339,6 +352,64 @@ real bug:
- Also removed the per-tap `log.Printf` debug lines in `SetCursorFromPoint`
(per-tap, per-glyph logcat noise).
### Phase 8 — selection on-device + Gio Android key limitation — DONE (2026-08-17)
Verified shift+arrow selection on the emulator, then root-caused why plain
hardware arrows never reached the editor on Android (see the Phase 8 note in the
status header): Gio v0.10's Android JNI drops modifier state, and plain arrow
*presses* are wrapped in `input.SystemEvent` for focus navigation. Fix in
main.go: explicit named `key.Filter`s for the four arrows (delivers the press
and suppresses the focus jump) plus app-side shift tracking (`NameShift`
press/release, reset on focus loss); forwarded events carry
`Shift: Modifiers.Contain(ModShift) || shiftDown`. Verified on-device: `keyevent
22` moves the caret, `keycombination 59 22` extends the selection, typing
replaces it. Documented as a Gio-version-sensitive workaround (architecture.md
§2.1).
### Phase 9 — touch selection — DONE (2026-08-17)
Implemented the Android-native touch selection model (v1 scope: word selection
+ resize + move + floating menu; no handle-tap-to-caret, no marquee, no
auto-scroll-to-cursor). See spec.md §2.2 and architecture.md §6.3 for the
contracts; the highlights:
- **Renderer reports finger positions, logic owns all geometry.** Touch events
are delivered as `ui.Point` (tap), `ui.DoubleTapPoint`, `ui.LongPressPoint`
(app-local Dp); the logic goroutine converts them to text coordinates with
the stored `EditorRegion` + scroll offset, hit-tests menu items itself, and
owns the menu rect, highlight range, and handle positions. The renderer only
draws what the frame snapshot says.
- **Long press needs invalidation to elapse.** Gio renders on demand; a
stationary finger produces no events and no frames, so the 400 ms threshold
could never be checked. The main loop polls `Renderer.PendingLongPress()`
and keeps `w.Invalidate()`-ing while a press is held still on the editor.
A non-grabbing raw pointer probe (`event.Op` tag) watches the press for
movement so a scroll cancels the pending long press; scroll/drag gestures
grab the pointer (`pointer.GrabCmd`) and cancel it the same way.
- **One event per Update is a trap on Android.** A tap's down+up routinely
land in one frame; `gesture.Click`/`gesture.Drag` return at most one event
per `Update` call, so the release sat in the queue until the *next* redraw —
which on an idle window never comes. The renderer now drains each gesture's
queue to exhaustion every frame (scroll already drains internally).
- **Clipboard crosses the goroutine boundary via channels; the ops run on the
main/Gio frame path** (`clipboardSetChan`/`pasteReqChan` logic→main,
`pasteChan` main→logic, all buffered so the harness never blocks logic).
On Android, `clipboard.ReadCmd` is answered synchronously during op flush
with a queued `transfer.DataEvent` that schedules **no** frame of its own,
so main invalidates the window after each read to guarantee a follow-up
frame in which the DataEvent is consumed and forwarded to logic.
- **Bugs found while validating on-device:** `clickReg` stored by value in a
map (per-frame press bookkeeping lost → long press never fired); `Menu.Draw`
didn't offset items by the menu origin; the clippable `drawElement` branch
skipped `SelDrag` registration (handles never drew); the press-probe query
omitted `Kinds` (a `pointer.Filter` with zero kinds matches nothing).
- **Verification:** unit tests (`touch_selection_test.go`, ~18) + e2e
(`touch_selection_e2e_test.go`) green under `-race`; on-device: long-press
word/blank, double-tap, handle-drag resize, single-tap copy/cut/paste
(including copy→paste and cut cycles through the real Android clipboard),
menu close-on-tap, plain-tap caret placement, and scroll-drag not firing a
long press.
## 6. File-size decision (re-framed)
v1 framed this as "accept a limit vs build a windowed editor." The live repo

View File

@ -56,11 +56,21 @@ elsewhere.
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
(verified on the Android emulator; Gio's mobile focus-navigation default
for arrow keys is overridden — see architecture.md §2.1).
- **Text selection:** shift+arrow (hardware keyboard) extends a selection;
insert, backspace, and delete replace the selected text; the IME replaces
a selection when the user types over it. The selection is highlighted in
the editor and pushed to the IME. (Shift state is tracked by the app,
since Gio's Android bridge drops modifier keys — architecture.md §2.1.)
- **Text selection:** two input paths. **Touch** (the Android-native model):
long-press selects the word under the finger (on a blank spot it places the
caret and offers a paste-only menu); double-tap selects the word; the
selection has drag handles at both ends (resize) and the highlighted body
can be dragged to move it (length preserved); a floating menu offers
copy/cut/paste when a selection is active and paste alone for a bare caret.
Any menu tap closes the menu; copy keeps the selection, cut removes it, and
paste inserts at the caret or replaces the selection. A plain tap still
places the caret and clears any selection; taps inside the visible menu are
ignored by the editor. **Hardware keyboard:** shift+arrow extends a
selection. In both cases insert, backspace, and delete replace the selected
text, and the IME replaces a selection when the user types over it. The
selection is highlighted in the editor and pushed to the IME. (Shift state
is tracked by the app, since Gio's Android bridge drops modifier keys —
architecture.md §2.1.)
- **Autosave:** every edit restarts a 1 s debounce; on expiry the full content
is written to disk by a worker. Failed writes are retried. This is the only
persistence mechanism.
@ -154,7 +164,6 @@ recorded here so future rounds don't mistake doc text for behavior:
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |
| Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. |
| In-file search, tabs, split view | **not implemented** | — |
| Touch selection (long-press, drag handles) | **not implemented** | Selection is reachable only via hardware-keyboard shift+arrow; touch users can position the caret by tap. |
| Files > 50 MB | **not supported** | `TooLarge` state instead. |
| Desktop / other platforms | **not supported** | Android-first. |

View File

@ -154,6 +154,24 @@ func (l *Logic) SearchQueryChan() chan<- string {
return l.searchQueryChan
}
// ClipboardSetChan returns the channel the logic goroutine uses to request a
// system clipboard write; the main goroutine executes the Gio clipboard op.
func (l *Logic) ClipboardSetChan() <-chan string {
return l.state.clipboardSetChan
}
// PasteReqChan returns the channel the logic goroutine uses to request the
// system clipboard content for paste.
func (l *Logic) PasteReqChan() <-chan struct{} {
return l.state.pasteReqChan
}
// PasteChan delivers system clipboard content to the logic goroutine for
// paste (tests use it to inject clipboard text without a main loop).
func (l *Logic) PasteChan() chan<- string {
return l.state.pasteChan
}
var TheState *State
var TheLogic *Logic
@ -232,6 +250,11 @@ func (l *Logic) Run() {
l.workerPool.DispatchNonBlocking(
pool.NewWriteFileTask(l.state.Editor.Filename, content, l.mockFS),
)
case p := <-l.state.pasteChan:
// Clipboard content arrived from the main goroutine: insert it
// (replacing any live selection, per the selection-aware edit rule).
HandlePaste(p)
l.emitFrame()
case req := <-l.inspectChan:
// Test-only: fn runs on the owner, preserving single ownership.
req.resp <- req.fn(l.state)

View File

@ -6,6 +6,7 @@ import (
"math"
"sort"
"time"
"unicode"
"unicode/utf8"
"gioui.org/io/key"
@ -79,7 +80,23 @@ type EditorState struct {
// IMEWindowText is the visible window text shown to the IME (the snippet).
// It is set during layout and is what an EditEvent.Range indexes into.
IMEWindowText string
Filename string
// --- Touch selection (v1) ---
// CaretDrag: after a long press on blank space a single draggable caret
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
// floating copy/cut/paste menu (positioned below the line of the selection
// end; items are recomputed when the menu is shown). SelDragging/
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
// handle). All of it is logic-owned; the renderer only reports finger
// positions and draws the geometry.
CaretDrag bool
MenuVisible bool
MenuRect ui.Region
MenuItems []ui.MenuItem
SelDragging bool
SelDragWhich int
SelDragRel int
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).
@ -156,6 +173,17 @@ type State struct {
Elems []ui.Element
lastEvictionTime time.Time // Throttles chunk eviction
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
// EditorRegion is the editor text region in app-local Dp, recomputed on
// every layout. Input handlers (tap, long press, selection drags) convert
// app-local points to text-local coordinates through it.
EditorRegion ui.Region
// Clipboard channels (touch-selection menu). ClipboardSetChan and
// PasteReqChan are consumed by the main goroutine, which executes the Gio
// clipboard ops and reports the read result back on PasteChan. Buffered so
// a harness without a main loop never blocks the logic goroutine.
clipboardSetChan chan string
pasteReqChan chan struct{}
pasteChan chan string
// Browser state (directly embedded per architecture §8)
Browser browser.BrowserState // Embedded, not a pointer
// Editor state
@ -180,6 +208,9 @@ func NewState() *State {
writeFailed: make(map[string]bool),
retryAttempts: make(map[string]int),
},
clipboardSetChan: make(chan string, 16),
pasteReqChan: make(chan struct{}, 16),
pasteChan: make(chan string, 16),
}
}
@ -434,12 +465,18 @@ func selActive() bool {
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
}
// ClearSelection drops the selection and the shift-anchor.
// ClearSelection drops the selection and the shift-anchor. It also dismisses
// the selection menu and cancels any in-progress drag bookkeeping: every path
// that clears the selection (tap, plain cursor move, any edit) should take
// the menu and handles down with it.
func ClearSelection() {
e := &TheState.Editor
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.MenuVisible = false
e.MenuItems = nil
e.SelDragging = false
}
// SetSelection selects the byte range [min(start,end), max(start,end)),
@ -531,6 +568,467 @@ func deleteRange(start, end int) {
}
}
// --- Touch selection (v1) ---------------------------------------------------
//
// Android-style touch interaction, layered over the byte-range selection
// model above. Single tap = caret (existing). Long press = select the word
// under the finger (or a draggable caret handle on blank space). Double tap
// = select the word. Handles: drag start/end to resize, drag the body to move
// the whole selection. Floating menu: Copy / Cut / Paste.
//
// Coordinate flow: the renderer reports app-local window Dp points (the same
// space as a tap). The functions below convert to text-local coordinates via
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
// All of these run on the logic goroutine (owner).
const (
menuItemW = ui.Dp(56) // width of one selection-menu button
menuH = ui.Dp(52) // selection-menu panel height
)
// isWordRune reports whether a rune is part of a selectable word (letters,
// digits, underscore).
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
// windowContent returns a content accessor for the active buffer.
func windowContent() func(a, b int) string {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
return func(a, b int) string {
return cb.Content(a, b)
}
}
s := TheState.Editor.Buffer
return func(a, b int) string {
if a < 0 {
a = 0
}
if b > len(s) {
b = len(s)
}
if a >= b {
return ""
}
return s[a:b]
}
}
func fileLenBytes() int {
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
return int(cb.FileLen())
}
return len(TheState.Editor.Buffer)
}
// wordRangeAt returns the byte range [start, end) of the word containing the
// rune at (or immediately before) pos. ok=false when neither is a word
// character (e.g. a space or punctuation).
func wordRangeAt(pos int) (start, end int, ok bool) {
content := windowContent()
fileLen := fileLenBytes()
if pos < 0 {
pos = 0
}
if pos > fileLen {
pos = fileLen
}
// 256-byte window around pos; words longer than that are clipped (rare).
a := pos - 256
if a < 0 {
a = 0
}
b := pos + 256
if b > fileLen {
b = fileLen
}
w := content(a, b)
rp := pos - a
// Rune starting at rp, and rune ending at rp (if any).
rAt, szAt := utf8.DecodeRuneInString(w[rp:])
var rBefore rune
var szBefore int
if rp > 0 {
j := rp - 1
for j >= 0 && w[j]&0xC0 == 0x80 {
j--
}
if j >= 0 {
rBefore, szBefore = utf8.DecodeRuneInString(w[j:])
}
}
// expandWord grows [l, r) over consecutive word runes (byte offsets in w).
expandWord := func(l, r int) (int, int) {
for l > 0 {
j := l - 1
for j >= 0 && w[j]&0xC0 == 0x80 {
j--
}
if j < 0 {
break
}
r, _ := utf8.DecodeRuneInString(w[j:])
if !isWordRune(r) {
break
}
l = j
}
for r < len(w) {
rr, sz := utf8.DecodeRuneInString(w[r:])
if sz == 0 || !isWordRune(rr) {
break
}
r += sz
}
return l, r
}
if szAt > 0 && isWordRune(rAt) {
l, r := expandWord(rp, rp+szAt)
return a + l, a + r, true
}
if szBefore > 0 && isWordRune(rBefore) {
l, r := expandWord(rp-szBefore, rp)
return a + l, a + r, true
}
return 0, 0, false
}
// pointInMenu reports whether the app-local Dp point is inside the visible
// selection menu panel.
func pointInMenu(x, y ui.Dp) bool {
r := TheState.Editor.MenuRect
return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
}
func hideSelectionMenu() {
e := &TheState.Editor
e.MenuVisible = false
e.MenuItems = nil
e.MenuRect = ui.Region{}
}
// showSelectionMenu positions the copy/cut/paste menu below the line that
// contains the selection end (or caret) and recomputes the items. Copy and
// Cut are offered only while a selection is active; Paste always.
func showSelectionMenu() {
e := &TheState.Editor
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 {
return
}
anchor := e.CursorPosition
if e.SelectionEnd > anchor {
anchor = e.SelectionEnd
}
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
if !ok {
return
}
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))
}
var items []ui.MenuItem
add := func(icon, label string) {
items = append(items, ui.MenuItem{
Icon: icon, Label: label,
X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH,
})
}
if selActive() {
add("copy", "Copy")
add("cut", "Cut")
}
add("paste", "Paste")
menuW := menuItemW * ui.Dp(len(items))
mx := glyphX - float64(menuW)/2
if mx < 8 {
mx = 8
}
if winW > 0 && mx+float64(menuW) > winW-8 {
mx = winW - float64(menuW) - 8
}
my := lineTop + float64(EditorLineHeight()) + 8 // below the line
if winH > 0 && my+float64(menuH) > winH-8 {
my = lineTop - float64(menuH) - 8 // flip above the line
}
if my < 8 {
my = 8
}
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
e.MenuItems = items
e.MenuVisible = true
}
// bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of
// the glyph at (or just before) the byte, and the top Y of the visual line
// containing it. ok=false when the layout is empty or the byte is outside
// the visible window. This is the inverse of textPosFromLocalPoint.
func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
layout := TheState.Editor.GlyphLayout
reg := TheState.EditorRegion
if len(layout.ByteOffsets) == 0 {
return 0, 0, false
}
base := glyphBase()
pos := absByte - base
windowLen := len(TheState.Editor.IMEWindowText)
if pos < 0 || pos > windowLen {
return 0, 0, false
}
lineHeight := float64(EditorLineHeight())
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= pos
})
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] > pos {
idx--
}
if idx < 0 {
idx = 0
}
if idx >= len(layout.ByteOffsets) {
// pos is at/past EOF: anchor on the last glyph.
idx = len(layout.ByteOffsets) - 1
}
gx := float64(reg.X) + float64(layout.X[idx])
// layout.Y is the baseline; the baseline offset within a line is always
// < lineHeight, so int(baseline/lineHeight) is the visual line index.
visualLine := int(float64(layout.Y[idx]) / lineHeight)
if visualLine < 0 {
visualLine = 0
}
lt := float64(reg.Y) + float64(visualLine)*lineHeight
return gx, lt, true
}
// tapInputGuard swallows editor input for a short window after a file opens:
// the tap that opened the file must not also reposition the caret.
func tapInputGuard() bool {
return time.Since(TheState.justOpenedAt) < 300*time.Millisecond
}
// HandleTapAt places the caret at app-local Dp point (x, y). A tap on the
// visible selection menu is ignored (the menu's own Tap interaction handles
// it); any other tap dismisses the menu and ends a caret drag.
func HandleTapAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.MenuVisible && pointInMenu(x, y) {
return
}
e.CaretDrag = false
hideSelectionMenu()
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
SetCursorFromPoint(localX, localY)
}
// HandleLongPressAt implements Android long-press: on a word it selects the
// word; on blank space it sets the caret there and shows a single draggable
// caret handle. The selection menu is shown either way.
func HandleLongPressAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.TooLarge {
return
}
if e.MenuVisible && pointInMenu(x, y) {
return
}
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
pos, ok := textPosFromLocalPoint(localX, localY)
if !ok {
return
}
if ws, we, found := wordRangeAt(pos); found {
SetSelection(ws, we)
e.CaretDrag = false
} else {
ClearSelection()
e.CursorPosition = pos
e.CaretDrag = true
}
showSelectionMenu()
}
// HandleDoubleTapAt selects the word under the finger (and shows the menu);
// on blank space it just places the caret.
func HandleDoubleTapAt(x, y ui.Dp) {
if tapInputGuard() {
return
}
e := &TheState.Editor
if e.TooLarge {
return
}
if e.MenuVisible && pointInMenu(x, y) {
return
}
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
pos, ok := textPosFromLocalPoint(localX, localY)
if !ok {
return
}
if ws, we, found := wordRangeAt(pos); found {
SetSelection(ws, we)
e.CaretDrag = false
showSelectionMenu()
} else {
SetCursorFromPoint(localX, localY)
}
}
// HandleSelDragEvt is the registered SelDrag interaction handler. The
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
func HandleSelDragEvt(data any) {
switch ev := data.(type) {
case ui.SelectionDragEvent:
selDragMove(ev.Which, ev.X, ev.Y)
case ui.SelectionDragEnd:
TheState.Editor.SelDragging = false
TheState.Editor.SelDragRel = 0
// The caret handle (long press on blank space) is a transient affordance;
// the selection handles come back from the selection state on the next
// frame, so only the caret mode is dropped here.
TheState.Editor.CaretDrag = false
}
}
// selDragMove applies one finger position of a selection/caret drag.
func selDragMove(which int, x, y ui.Dp) {
e := &TheState.Editor
localX := float64(x - TheState.EditorRegion.X)
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
pos, ok := textPosFromLocalPoint(localX, localY)
if !e.SelDragging {
e.SelDragging = true
e.SelDragWhich = which
if which == 2 && ok {
// Body drag: the grab fixes the finger's offset from the selection
// start; the selection itself moves on later events.
e.SelDragRel = pos - e.SelectionStart
if e.SelDragRel < 0 {
e.SelDragRel = 0
}
return
}
// Start/end/caret handles: the first event already positions the
// handle (no grab offset needed).
}
if !ok {
return // finger outside the laid-out window: keep last position
}
switch e.SelDragWhich {
case 0: // start handle
if pos > e.SelectionEnd {
pos = e.SelectionEnd
}
SetSelection(pos, e.SelectionEnd)
case 1: // end handle
if pos < e.SelectionStart {
pos = e.SelectionStart
}
SetSelection(e.SelectionStart, pos)
case 2: // body: move the whole selection, preserving length
selLen := e.SelectionEnd - e.SelectionStart
ns := pos - e.SelDragRel
fl := fileLenBytes()
if ns < 0 {
ns = 0
}
if ns+selLen > fl {
ns = fl - selLen
}
SetSelection(ns, ns+selLen)
case 3: // caret drag handle
e.CursorPosition = pos
}
}
// HandleMenuTap hit-tests a tap on the menu panel and runs the tapped item.
func HandleMenuTap(x, y ui.Dp) {
e := &TheState.Editor
if !e.MenuVisible || !pointInMenu(x, y) {
return
}
idx := int((x - e.MenuRect.X) / menuItemW)
if idx < 0 || idx >= len(e.MenuItems) {
return
}
switch e.MenuItems[idx].Icon {
case "copy":
handleMenuCopy()
case "cut":
handleMenuCut()
case "paste":
handleMenuPaste()
}
}
// selectedText returns the selected bytes.
func selectedText() (string, bool) {
e := &TheState.Editor
if !selActive() {
return "", false
}
if cb := e.ChunkedBuffer; cb != nil {
return cb.Content(e.SelectionStart, e.SelectionEnd), true
}
return e.Buffer[e.SelectionStart:e.SelectionEnd], true
}
func handleMenuCopy() {
text, ok := selectedText()
if !ok {
return
}
TheState.clipboardSetChan <- text
// The selection (and its highlight) stays; only the menu goes away,
// matching Android behaviour.
hideSelectionMenu()
}
func handleMenuCut() {
e := &TheState.Editor
text, ok := selectedText()
if !ok {
return
}
TheState.clipboardSetChan <- text
deleteRange(e.SelectionStart, e.SelectionEnd)
markDirty()
e.CursorPosition = e.SelectionStart
ClearSelection()
}
func handleMenuPaste() {
if TheState.Editor.TooLarge {
return
}
TheState.pasteReqChan <- struct{}{}
// The read is asynchronous (DataEvent on a later frame), but the menu
// has served its purpose and closes immediately, as on Android.
hideSelectionMenu()
}
// HandlePaste inserts the system clipboard text (replacing a live selection,
// per the selection-aware edit rule). Runs on the logic goroutine.
func HandlePaste(text string) {
if text == "" {
return
}
HandleInsert(text)
}
// HandleHome moves the cursor to the start of the current visual line.
func HandleHome() {
layout := TheState.Editor.GlyphLayout
@ -1066,6 +1564,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
W: screenWidth - margin*2,
H: editorH,
}
// Stored for the input handlers (tap / long press / drags), which convert
// app-local Dp points to text-local coordinates through it.
TheState.EditorRegion = editorRegion
// Compute max scroll offset from the last line baseline reported by the renderer.
// lastLineY is the shaper's Y value for the last line's baseline.
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
@ -1216,30 +1717,41 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
[]ui.Interaction{
{Gesture: ui.Scroll, Handler: HandleScroll},
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
// The Tap interaction also carries the long-press and double-tap
// events the renderer derives from the same gesture.Click (they share
// the editor's click region). The renderer converts all of them to
// text-local coordinates through EditorRegion + tapLocalY.
{Gesture: ui.Tap, Handler: func(data any) {
// Swallow the tap that opened the file. Its gesture belongs to the
// browser row we just left and must not position the cursor in the
// editor (a deliberate tap-to-position happens later, well past this
// window).
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
return
}
if pt, ok := data.(ui.Point); ok {
// Window-space tap -> window-relative text-local coordinates.
// (See tapLocalY: the GlyphLayout is window-relative, so the Y must
// be too — never add the full scroll offset here.)
localX := float64(pt.X - editorRegion.X)
localY := tapLocalY(pt.Y, editorRegion.Y, TheState.ScrollOffset)
SetCursorFromPoint(localX, localY)
// The just-opened guard (swallowing the tap that opened the file)
// lives inside the Handle* functions.
switch pt := data.(type) {
case ui.Point:
HandleTapAt(pt.X, pt.Y)
case ui.DoubleTapPoint:
HandleDoubleTapAt(pt.X, pt.Y)
case ui.LongPressPoint:
HandleLongPressAt(pt.X, pt.Y)
}
}},
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
},
)
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
// for Gio to deliver key events to this element.
editorElem.Focused = TheState.FocusedElementID == "editor_text"
// Caret handle visibility (long press on blank space).
editorElem.CaretDrag = TheState.Editor.CaretDrag
return []ui.Element{statusBar, editorElem, bottomBar}
elems := []ui.Element{statusBar, editorElem, bottomBar}
// The selection menu is added last so it draws on top of the editor.
if TheState.Editor.MenuVisible {
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
if pt, ok := data.(ui.Point); ok {
HandleMenuTap(pt.X, pt.Y)
}
}))
}
return elems
}
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
@ -1254,13 +1766,25 @@ func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
return float64(ptY-regionTopY) + math.Mod(float64(scrollOffset), float64(EditorLineHeight()))
}
// SetCursorFromPoint updates the cursor position based on screen coordinates (Dp).
// SetCursorFromPoint updates the cursor position based on text-local
// coordinates (Dp, window-relative: x from the text region's left edge, y as
// produced by tapLocalY). A tap is an explicit cursor placement: it always
// clears any selection.
func SetCursorFromPoint(x, y float64) {
// A tap is an explicit cursor placement: it always clears any selection.
ClearSelection()
if pos, ok := textPosFromLocalPoint(x, y); ok {
TheState.Editor.CursorPosition = pos
}
}
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
// an absolute byte offset in the buffer. ok=false when the layout is empty or
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
// selection-drag handlers.
func textPosFromLocalPoint(x, y float64) (int, bool) {
layout := TheState.Editor.GlyphLayout
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
return
return 0, false
}
base := glyphBase()
@ -1329,7 +1853,7 @@ func SetCursorFromPoint(x, y float64) {
}
}
if len(groups[visualLine].indices) == 0 {
return
return 0, false
}
targetGroup := groups[visualLine]
@ -1355,7 +1879,7 @@ func SetCursorFromPoint(x, y float64) {
content, err := buf.FullContent()
if err != nil {
log.Printf("Error getting full content: %v", err)
return
return 0, false
}
fileContent = content
} else {
@ -1363,11 +1887,10 @@ func SetCursorFromPoint(x, y float64) {
}
r, size := utf8.DecodeRuneInString(fileContent[start:])
if r == '\n' {
TheState.Editor.CursorPosition = start
return start, true
} else {
TheState.Editor.CursorPosition = start + size
return start + size, true
}
return
}
// 5. Otherwise, find the closest glyph on this line.
@ -1389,6 +1912,7 @@ func SetCursorFromPoint(x, y float64) {
}
}
if bestIdx != -1 {
TheState.Editor.CursorPosition = base + layout.ByteOffsets[bestIdx]
return base + layout.ByteOffsets[bestIdx], true
}
return 0, false
}

View File

@ -0,0 +1,368 @@
package editor
import (
"testing"
"pad/internal/ui"
)
// touchSelState sets up a fresh string-buffer state with a single-line
// GlyphLayout: 11 glyphs at x=10*i (advance 10), line top at y=0.
// EditorRegion is {X:16, Y:100, W:379, H:700} so text-local x = pt.X-16 and
// tapLocalY(y,100,0) = pt.Y-100 (scroll offset 0).
func touchSelState(content string) {
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
// The visible window (what layoutFrame sets every frame): the whole
// string buffer. bytePosToScreenXY bounds-checks against it.
TheState.Editor.IMEWindowStartByte = 0
TheState.Editor.IMEWindowText = content
n := len(content)
bo := make([]int, n)
xs := make([]ui.Dp, n)
ys := make([]ui.Dp, n)
ad := make([]ui.Dp, n)
for i := 0; i < n; i++ {
bo[i] = i
xs[i] = ui.Dp(10 * i)
ys[i] = 0
ad[i] = 10
}
TheState.Editor.GlyphLayout = ui.GlyphLayout{
ByteOffsets: bo,
X: xs,
Y: ys,
Advance: ad,
}
}
// assertMenu checks menu visibility and item icons.
func assertMenu(t *testing.T, wantVisible bool, wantIcons ...string) {
t.Helper()
e := TheState.Editor
if e.MenuVisible != wantVisible {
t.Fatalf("MenuVisible = %v, want %v", e.MenuVisible, wantVisible)
}
if wantVisible {
if len(e.MenuItems) != len(wantIcons) {
t.Fatalf("menu has %d items, want %d", len(e.MenuItems), len(wantIcons))
}
for i, it := range e.MenuItems {
if it.Icon != wantIcons[i] {
t.Errorf("menu item %d icon = %q, want %q", i, it.Icon, wantIcons[i])
}
}
}
}
func TestWordRangeAt(t *testing.T) {
touchSelState("hello world")
// Inside a word.
s, e, ok := wordRangeAt(3)
if !ok || s != 0 || e != 5 {
t.Errorf("wordRangeAt(3) = [%d,%d) ok=%v, want [0,5) true", s, e, ok)
}
// Second word.
s, e, ok = wordRangeAt(7)
if !ok || s != 6 || e != 11 {
t.Errorf("wordRangeAt(7) = [%d,%d) ok=%v, want [6,11) true", s, e, ok)
}
// Word boundary: position right after a word (space after) still finds
// the word ending there.
s, e, ok = wordRangeAt(5)
if !ok || s != 0 || e != 5 {
t.Errorf("wordRangeAt(5) = [%d,%d) ok=%v, want [0,5) true", s, e, ok)
}
// EOF: the position is right after the last word -> that word.
s, e, ok = wordRangeAt(11)
if !ok || s != 6 || e != 11 {
t.Errorf("wordRangeAt(11) = [%d,%d) ok=%v, want [6,11) true", s, e, ok)
}
}
func TestWordRangeAt_PunctuationAndIdentifiers(t *testing.T) {
touchSelState("foo,bar_baz 123")
// "bar_baz" is one word (underscore joins).
s, e, ok := wordRangeAt(7)
if !ok || s != 4 || e != 11 {
t.Errorf("wordRangeAt(7) = [%d,%d) ok=%v, want [4,11) true", s, e, ok)
}
// "123".
s, e, ok = wordRangeAt(14)
if !ok || s != 12 || e != 15 {
t.Errorf("wordRangeAt(14) = [%d,%d) ok=%v, want [12,15) true", s, e, ok)
}
// Position at the comma, right after "foo": the word ending there.
s, e, ok = wordRangeAt(3)
if !ok || s != 0 || e != 3 {
t.Errorf("wordRangeAt(3) = [%d,%d) ok=%v, want [0,3) true", s, e, ok)
}
}
func TestHandleLongPressAt_SelectsWord(t *testing.T) {
touchSelState("hello world")
// Long press on the first glyph of "hello" (window pt: x=16+2=18, y=108).
HandleLongPressAt(18, 108)
assertSelection(t, 0, 5)
assertMenu(t, true, "copy", "cut", "paste")
if TheState.Editor.SelectionAnchor != 0 {
t.Errorf("anchor = %d, want 0", TheState.Editor.SelectionAnchor)
}
if TheState.Editor.CursorPosition != 5 {
t.Errorf("cursor = %d, want 5", TheState.Editor.CursorPosition)
}
}
func TestHandleLongPressAt_BlankGivesCaretDrag(t *testing.T) {
// Line ends with punctuation so the position right after the last glyph
// is NOT adjacent to a word rune (a blank spot, not a word edge).
touchSelState("hello world.")
// Long press far right of the last glyph (x=16+150=166, past x=120).
HandleLongPressAt(166, 108)
assertSelection(t, -1, -1)
if !TheState.Editor.CaretDrag {
t.Errorf("CaretDrag = false, want true (long press on blank)")
}
// No selection -> the menu offers only Paste.
assertMenu(t, true, "paste")
if TheState.Editor.CursorPosition != 12 {
t.Errorf("cursor = %d, want 12 (end of line)", TheState.Editor.CursorPosition)
}
}
func TestHandleLongPressAt_AfterLastWordSelectsIt(t *testing.T) {
// A long press in the blank just past the last word selects that word
// (the word adjacent to the tap), matching Android's word selection.
touchSelState("hello world")
HandleLongPressAt(166, 108)
assertSelection(t, 6, 11)
}
func TestHandleLongPressAt_InGapBetweenWords(t *testing.T) {
touchSelState("hello world") // two spaces
// Long press on the SECOND space (glyph 6, center x=65; pt 16+65=81):
// no word rune at or immediately before the position -> caret.
HandleLongPressAt(81, 108)
assertSelection(t, -1, -1)
if !TheState.Editor.CaretDrag {
t.Errorf("CaretDrag = false, want true (long press in word gap)")
}
if TheState.Editor.CursorPosition != 6 {
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
}
}
func TestHandleDoubleTapAt_SelectsWord(t *testing.T) {
touchSelState("hello world")
HandleDoubleTapAt(18, 108)
assertSelection(t, 0, 5)
assertMenu(t, true, "copy", "cut", "paste")
}
func TestHandleTapAt_MovesCaretAndClearsSelection(t *testing.T) {
touchSelState("hello world")
// Establish a selection, then a plain tap clears it and moves the caret.
HandleLongPressAt(18, 108)
assertSelection(t, 0, 5)
// Tap on the 'w' of "world" (glyph 6, x=60..70; local 62 -> pt 78).
HandleTapAt(78, 108)
assertSelection(t, -1, -1)
if TheState.Editor.CaretDrag {
t.Errorf("CaretDrag = true after plain tap, want false")
}
if TheState.Editor.CursorPosition != 6 {
t.Errorf("cursor = %d, want 6", TheState.Editor.CursorPosition)
}
}
func TestHandleTapAt_InsideMenuIsIgnored(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108)
if !TheState.Editor.MenuVisible {
t.Fatal("expected menu visible")
}
posBefore := TheState.Editor.CursorPosition
selStartBefore := TheState.Editor.SelectionStart
// Tap inside the menu (top-left item).
m := TheState.Editor.MenuRect
HandleTapAt(m.X+5, m.Y+5)
if TheState.Editor.CursorPosition != posBefore {
t.Errorf("cursor moved to %d after tap in menu, want %d (ignored)", TheState.Editor.CursorPosition, posBefore)
}
if TheState.Editor.SelectionStart != selStartBefore {
t.Errorf("selection changed after tap in menu")
}
if !TheState.Editor.MenuVisible {
t.Errorf("menu hidden after tap in menu, want still visible")
}
}
func TestSelDrag_EndHandleExtends(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello" [0,5)
// Drag the end handle (which=1) past the last glyph (local 115 > 110)
// so the whole line is selected.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 1, X: 16 + 115, Y: 108})
assertSelection(t, 0, 11)
}
func TestSelDrag_StartHandleExtends(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello" [0,5)
// Drag the start handle (which=0) to the 'o' (glyph 4, x=40..50; local 45 -> pt 61).
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 45, Y: 108})
assertSelection(t, 4, 5)
}
func TestSelDrag_StartHandleCollapseClears(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello" [0,5)
// Drag the start handle to the end of the selection -> zero-length -> cleared.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 0, X: 16 + 52, Y: 108})
assertSelection(t, -1, -1)
assertMenu(t, false)
}
func TestSelDrag_BodyMovesPreservingLength(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello" [0,5)
// Grab the body above the 'l' (glyph 2, x=20..30; local 25 -> pt 41):
// SelDragRel = pos(2) - selStart(0) = 2.
HandleSelDragEvt(ui.SelectionDragEvent{Which: 2, X: 16 + 25, Y: 108})
// Move the body so the finger is above the 'r' of world (glyph 9, x=90..100; local 95 -> pt 111):
// pos = 9 -> new start = 9-2 = 7 -> [7,12) clamped to [7,11)...
HandleSelDragEvt(ui.SelectionDragEvent{Which: 2, X: 16 + 95, Y: 108})
s := TheState.Editor
if s.SelectionStart != 6 || s.SelectionEnd != 11 {
// length 5 preserved, clamped at EOF: [6,11)
t.Errorf("body drag selection = [%d,%d), want [6,11)", s.SelectionStart, s.SelectionEnd)
}
HandleSelDragEvt(ui.SelectionDragEnd{})
}
func TestSelDrag_CaretHandleMovesCursor(t *testing.T) {
touchSelState("hello world.") // punctuation keeps the right side blank
HandleLongPressAt(166, 108) // blank -> caret drag at 12
if !TheState.Editor.CaretDrag {
t.Fatal("expected caret drag mode")
}
// Drag the caret handle (which=3) to glyph 2 (local 25 -> pt 41).
HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108})
HandleSelDragEvt(ui.SelectionDragEvent{Which: 3, X: 16 + 25, Y: 108})
if TheState.Editor.CursorPosition != 2 {
t.Errorf("cursor = %d after caret drag, want 2", TheState.Editor.CursorPosition)
}
// The handle stays up while dragging...
if !TheState.Editor.CaretDrag {
t.Errorf("CaretDrag cleared mid-drag, want true")
}
HandleSelDragEvt(ui.SelectionDragEnd{})
if TheState.Editor.CaretDrag {
t.Errorf("CaretDrag not cleared on drag end")
}
}
func TestMenuCopy(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello"
m := TheState.Editor.MenuRect
// Tap the copy item (first item, X offset 0).
HandleMenuTap(m.X+10, m.Y+10)
select {
case text := <-TheState.clipboardSetChan:
if text != "hello" {
t.Errorf("clipboard = %q, want %q", text, "hello")
}
default:
t.Fatal("no clipboard write after copy")
}
// Copy does not clear the selection (the highlight stays), but the
// menu closes, as on Android.
assertSelection(t, 0, 5)
assertMenu(t, false)
}
func TestMenuCut(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello"
m := TheState.Editor.MenuRect
// Tap the cut item (second item).
HandleMenuTap(m.X+ui.Dp(menuItemW)+10, m.Y+10)
select {
case text := <-TheState.clipboardSetChan:
if text != "hello" {
t.Errorf("clipboard = %q, want %q", text, "hello")
}
default:
t.Fatal("no clipboard write after cut")
}
if got := TheState.Editor.Buffer; got != " world" {
t.Errorf("buffer = %q after cut, want %q", got, " world")
}
assertSelection(t, -1, -1)
assertMenu(t, false)
if TheState.Editor.CursorPosition != 0 {
t.Errorf("cursor = %d after cut, want 0", TheState.Editor.CursorPosition)
}
}
func TestMenuPaste(t *testing.T) {
touchSelState("hello")
HandleLongPressAt(18, 108) // selects "hello"
// Paste replaces the live selection (via the same path the Run loop uses
// when the main goroutine forwards clipboard content).
HandlePaste("XY")
if got := TheState.Editor.Buffer; got != "XY" {
t.Errorf("buffer = %q after paste, want %q", got, "XY")
}
assertSelection(t, -1, -1)
assertMenu(t, false)
if TheState.Editor.CursorPosition != 2 {
t.Errorf("cursor = %d after paste, want 2", TheState.Editor.CursorPosition)
}
}
func TestMenuPaste_NoSelectionInsertsAtCursor(t *testing.T) {
touchSelState("hello")
TheState.Editor.CursorPosition = 5
HandlePaste(",")
if got := TheState.Editor.Buffer; got != "hello," {
t.Errorf("buffer = %q after paste, want %q", got, "hello,")
}
if TheState.Editor.CursorPosition != 6 {
t.Errorf("cursor = %d after paste, want 6", TheState.Editor.CursorPosition)
}
}
func TestMenuTapPaste_HidesMenuAndRequestsRead(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello"
m := TheState.Editor.MenuRect
// Tap the paste item (third item).
HandleMenuTap(m.X+2*ui.Dp(menuItemW)+10, m.Y+10)
select {
case <-TheState.pasteReqChan:
default:
t.Fatal("no paste request after menu paste tap")
}
// The menu closes immediately (the read is asynchronous); the
// selection stays until the pasted content arrives.
assertMenu(t, false)
assertSelection(t, 0, 5)
}
func TestSelectionEdit_CutThenTypeReplaces(t *testing.T) {
touchSelState("hello world")
HandleLongPressAt(18, 108) // selects "hello"
HandleInsert("goodbye")
if got := TheState.Editor.Buffer; got != "goodbye world" {
t.Errorf("buffer = %q, want %q", got, "goodbye world")
}
}

View File

@ -0,0 +1,207 @@
package e2e_test
import (
"testing"
"time"
"pad/internal/editor"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// editorTapHandler mimics the production tap/long-press/double-tap closure
// that layout attaches to the editor's TextField (a type switch over the
// gesture data). e2e injects it because the harness has no renderer to derive
// the gestures.
func editorTapHandler(data any) {
switch pt := data.(type) {
case ui.Point:
editor.HandleTapAt(pt.X, pt.Y)
case ui.DoubleTapPoint:
editor.HandleDoubleTapAt(pt.X, pt.Y)
case ui.LongPressPoint:
editor.HandleLongPressAt(pt.X, pt.Y)
}
}
// setupTouchE2E opens a real file, forces the just-opened tap guard into the
// past, installs a single-line synthetic GlyphLayout (11 glyphs at x=10*i) and
// returns the harness plus the editor region (app-local Dp).
func setupTouchE2E(t *testing.T) (*e2e.Harness, ui.Region) {
t.Helper()
content := "hello world\nsecond line\n"
h, _ := realFileHarness(t, "notes.txt", content)
h.SendConfig(780, 1688)
// Let the just-opened guard (300 ms) lapse so test taps are not swallowed.
time.Sleep(350 * time.Millisecond)
// Synthetic single-line layout for "hello world" (the visible window of a
// small file is the whole buffer, so window-relative == file-relative).
if err := h.WithState(func(st *editor.State) {
n := 11
bo := make([]int, n)
xs := make([]ui.Dp, n)
ys := make([]ui.Dp, n)
ad := make([]ui.Dp, n)
for i := 0; i < n; i++ {
bo[i] = i
xs[i] = ui.Dp(10 * i)
ys[i] = 0
ad[i] = 10
}
st.Editor.GlyphLayout = ui.GlyphLayout{ByteOffsets: bo, X: xs, Y: ys, Advance: ad}
}); err != nil {
t.Fatalf("set GlyphLayout: %v", err)
}
// Wait for a frame so EditorRegion is set, then read it.
if _, err := h.WaitForFrame(e2e.DefaultTimeout); err != nil {
t.Fatalf("wait for frame: %v", err)
}
regAny, err := h.Inspect(func(st *editor.State) any { return st.EditorRegion })
if err != nil {
t.Fatalf("inspect region: %v", err)
}
reg := regAny.(ui.Region)
if reg.W <= 0 {
t.Fatalf("editor region not laid out: %+v", reg)
}
return h, reg
}
// frameHasMenu reports whether any captured frame carries a Menu element.
func frameHasMenu(frames [][]ui.Element) bool {
for _, f := range frames {
for _, el := range f {
if el.Type() == "menu" {
return true
}
}
}
return false
}
func selectionOf(t *testing.T, h *e2e.Harness) (int, int) {
t.Helper()
v, err := h.Inspect(func(st *editor.State) any {
return [2]int{st.Editor.SelectionStart, st.Editor.SelectionEnd}
})
if err != nil {
t.Fatalf("inspect selection: %v", err)
}
s := v.([2]int)
return s[0], s[1]
}
func TestRealFile_TouchSelectionLongPressWord(t *testing.T) {
h, reg := setupTouchE2E(t)
defer h.Cleanup()
before := h.FrameCount()
h.SendInput([]ui.InputEvent{{
Handler: editorTapHandler,
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8}, // glyph 0 of "hello"
}})
frames, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout)
if err != nil {
t.Fatalf("wait frame: %v", err)
}
s, e := selectionOf(t, h)
if s != 0 || e != 5 {
t.Errorf("selection = [%d,%d), want [0,5) (\"hello\")", s, e)
}
if !frameHasMenu(frames) {
t.Error("no Menu element in frames after long press")
}
}
func TestRealFile_TouchSelectionDragEndHandle(t *testing.T) {
h, reg := setupTouchE2E(t)
defer h.Cleanup()
// Long press selects "hello".
before := h.FrameCount()
h.SendInput([]ui.InputEvent{{
Handler: editorTapHandler,
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8},
}})
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
t.Fatalf("wait frame: %v", err)
}
// Drag the end handle (which=1) past the last glyph: selects the line.
before = h.FrameCount()
h.SendInput([]ui.InputEvent{
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, Y: reg.Y + 8}},
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEvent{Which: 1, X: reg.X + 115, Y: reg.Y + 8}},
{Handler: editor.HandleSelDragEvt, Data: ui.SelectionDragEnd{}},
})
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
t.Fatalf("wait frame: %v", err)
}
s, e := selectionOf(t, h)
if s != 0 || e != 11 {
t.Errorf("selection = [%d,%d), want [0,11) after end-handle drag", s, e)
}
}
func TestRealFile_TouchSelectionMenuCopyPaste(t *testing.T) {
h, reg := setupTouchE2E(t)
defer h.Cleanup()
// Long press selects "hello" and shows the menu.
before := h.FrameCount()
h.SendInput([]ui.InputEvent{{
Handler: editorTapHandler,
Data: ui.LongPressPoint{X: reg.X + 2, Y: reg.Y + 8},
}})
frames, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout)
if err != nil {
t.Fatalf("wait frame: %v", err)
}
if !frameHasMenu(frames) {
t.Fatal("no Menu element after long press")
}
// Read the menu geometry from the owner and tap the first item (Copy)
// at its center.
menuAny, err := h.Inspect(func(st *editor.State) any {
return struct {
R ui.Region
Items []ui.MenuItem
}{st.Editor.MenuRect, st.Editor.MenuItems}
})
if err != nil {
t.Fatalf("inspect menu: %v", err)
}
menu := menuAny.(struct {
R ui.Region
Items []ui.MenuItem
})
if len(menu.Items) == 0 || menu.R.W <= 0 {
t.Fatal("menu not visible with items")
}
tapX := menu.R.X + menu.Items[0].W/2
tapY := menu.R.Y + menu.R.H/2
before = h.FrameCount()
h.SendInput([]ui.InputEvent{
{Handler: func(d any) {
p := d.(ui.Point)
editor.HandleMenuTap(p.X, p.Y)
}, Data: ui.Point{X: tapX, Y: tapY}},
})
if _, err := h.WaitForFrameCount(before+1, e2e.DefaultTimeout); err != nil {
t.Fatalf("wait frame: %v", err)
}
// The copy went to the clipboard channel; the harness has no main loop, so
// feed it back through PasteChan and verify the selection is replaced.
h.Logic().PasteChan() <- "XY"
got, err := h.FullContent()
if err != nil {
t.Fatalf("full content: %v", err)
}
// "hello" (the selection) is replaced by "XY".
want := "XY world\nsecond line\n"
if got != want {
t.Errorf("content after paste = %q, want %q", got, want)
}
}

View File

@ -192,10 +192,13 @@ type TextField struct {
// in-app highlight.
SelectionStart int
SelectionEnd int
ScrollOffset Dp
VisibleLines []Line
WordWrap bool
WrapWidth Dp
// CaretDrag is set after a long press on blank space: a single caret
// handle is shown and can be dragged to move the caret.
CaretDrag bool
ScrollOffset Dp
VisibleLines []Line
WordWrap bool
WrapWidth Dp
}
func (tf TextField) Type() string { return "textfield" }
@ -290,7 +293,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
r.lastSelStart = -1
r.lastSelCaret = -1
}
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd)
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag)
}
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
@ -849,6 +852,10 @@ const (
Scroll
KeyDown
KeyUp
// SelDrag is a selection-handle / selection-body / caret-handle drag.
// The renderer registers the underlying gesture.Drag ops itself (it owns
// the handle geometry); this interaction just delivers the logic handler.
SelDrag
)
// Interaction pairs a gesture type with a handler function.
@ -877,6 +884,86 @@ type Point struct {
X, Y Dp
}
// LongPressPoint is a touch that was held still for the long-press duration.
// Coordinates are app-local window Dp (the same space as Point from a tap).
type LongPressPoint struct {
X, Y Dp
}
// DoubleTapPoint is the second tap of a double tap. App-local window Dp.
type DoubleTapPoint struct {
X, Y Dp
}
// SelectionDragEvent is emitted while a selection handle (Which 0 = start,
// 1 = end), the selection body (Which 2 = move whole selection) or the
// caret drag handle (Which 3) is being dragged. X/Y are app-local window Dp.
type SelectionDragEvent struct {
Which int
X, Y Dp
}
// SelectionDragEnd is emitted when a selection/caret drag is released.
type SelectionDragEnd struct{}
// MenuItem is one button in the selection menu.
// X/Y/W/H are relative to the Menu region.
type MenuItem struct {
Icon string // "copy", "cut", "paste"
Label string
X, Y, W, H Dp
}
// Menu is the floating selection menu (Copy / Cut / Paste). The logic goroutine
// positions it and decides the items; the renderer draws it and routes a tap
// to the element's Tap interaction with the tapped Point, which the logic
// hit-tests against its own items (single source of truth for geometry).
type Menu struct {
id string
region Region
visible bool
items []MenuItem
tap []Interaction
}
func NewMenu(id string, region Region, items []MenuItem, tapHandler func(any)) Menu {
return Menu{
id: id,
region: region,
visible: true,
items: items,
tap: []Interaction{{Gesture: Tap, Handler: tapHandler}},
}
}
func (m Menu) Type() string { return "menu" }
func (m Menu) Region() Region { return m.region }
func (m Menu) Visible() bool { return m.visible }
func (m Menu) ID() string { return m.id }
func (m Menu) Interactions() []Interaction { return m.tap }
func (m Menu) Items() []MenuItem { return m.items }
func (m Menu) String() string {
return fmt.Sprintf("Menu[%s] region=%+v items=%d", m.id, m.region, len(m.items))
}
// Draw renders the menu panel and its items. Click registration happens in
// the renderer's leaf-element path (Tap over the whole panel).
func (m Menu) Draw(gtx layout.Context, r *Renderer) {
// Panel background (light, like an Android floating menu).
r.drawBg(gtx, m.region, Color{R: 245, G: 245, B: 245, A: 255})
for _, it := range m.items {
// Item X/Y are relative to the menu region; offset to app coordinates.
ofx, ofy := m.region.X, m.region.Y
img := r.icon(it.Icon)
iconSize := it.H / 2
iconX := ofx + it.X + (it.W-iconSize)/2
iconY := ofy + it.Y + 2
r.drawPng(gtx, img, Region{X: iconX, Y: iconY, W: iconSize, H: iconSize}, iconSize, iconSize)
r.drawText(gtx, it.Label, unit.Sp(10), Region{X: ofx + it.X, Y: ofy + it.Y + iconSize + 3, W: it.W, H: 12}, AlignCenter, Color{R: 30, G: 30, B: 30, A: 255}, "")
}
}
// InputEvent represents a user input event with its handler.
type InputEvent struct {
Handler func(any)

View File

@ -36,8 +36,21 @@ var iconFS embed.FS
type clickReg struct {
click *gesture.Click
handler func(any)
// pressAt/pressPos record the current press (set on KindPress) so the
// per-frame long-press check knows how long the finger has been still.
pressAt time.Time
pressPos image.Point
longFired bool
}
// longPressDuration is how long a still press must hold before a long-press
// fires (Android uses ~500ms; 400ms feels snappier for text selection).
const longPressDuration = 400 * time.Millisecond
// longPressSlopPx is how far the finger may drift (px) before a pending
// long press is cancelled (that motion becomes a scroll/drag instead).
const longPressSlopPx = 8
// keyReg pairs a handler for key events.
type keyReg struct {
Handler func(any)
@ -59,7 +72,7 @@ type Renderer struct {
shp *text.Shaper
scale float32 // px-per-Dp for the current draw pass; set in Draw
icons map[string]image.Image
clicks map[string]clickReg
clicks map[string]*clickReg
Keys map[string]keyReg // Exported Keys map
scrolls map[string]scrollReg
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
@ -77,6 +90,35 @@ type Renderer struct {
lastSnippet key.Snippet
lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection
lastSelCaret int // window-relative rune index of last-pushed selection end/caret
// Long-press detection. pressProbe is a plain event tag observing raw
// pointer events inside the editor text region: gesture.Click reports
// nothing until release, so a long press (finger held still for
// longPressDuration) can only be detected this way. ppMoved cancels the
// pending long press once the finger leaves longPressSlopPx (it is a
// scroll/drag then, not a press). longPressID gates the long-press to the
// editor's click reg (browser rows etc. don't long-press). ppLast is in
// f32.Point because pointer.Event.Position is window-space f32.
pressProbe struct{}
ppLast f32.Point
ppActive bool
ppMoved bool
longPressID string
// Selection / caret drag handles (0 = start, 1 = end, 2 = body, 3 = caret
// handle). Registered clipped in drawWrappedText only while a selection or
// caret handle is visible; a gesture.Drag grabs the pointer once movement
// exceeds slop, which cancels the scroll and click handlers so a handle
// drag never fights a fling. selDragEmitting tracks whether a Drag event
// was delivered for the current gesture (so a plain tap on a handle does
// not emit a spurious SelectionDragEnd).
selDragStart gesture.Drag
selDragEnd gesture.Drag
selDragBody gesture.Drag
selDragCaret gesture.Drag
selDragsOn [4]bool
selDragEmitting [4]bool
selDragHandler func(any)
}
// New creates a new Renderer.
@ -85,7 +127,7 @@ func New(th Theme, shp *text.Shaper) *Renderer {
theme: th,
shp: shp,
icons: make(map[string]image.Image),
clicks: make(map[string]clickReg),
clicks: make(map[string]*clickReg),
Keys: make(map[string]keyReg),
scrolls: make(map[string]scrollReg),
gioEditors: make(map[string]*widget.Editor),
@ -169,10 +211,10 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
case Tap:
reg, ok := r.clicks[id]
if !ok {
reg = clickReg{click: &gesture.Click{}}
reg = &clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
reg.handler = interaction.Handler
r.clicks[id] = reg
// Register click within current clip context
reg.click.Add(gtx.Ops)
case Scroll:
@ -184,6 +226,11 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
reg.scroll.Add(gtx.Ops)
reg.handler = interaction.Handler
r.scrolls[id] = reg
case SelDrag:
// The renderer registers the actual gesture.Drag ops in
// drawWrappedText (it owns the handle geometry); this only records the
// logic handler that receives the drag events.
r.selDragHandler = interaction.Handler
}
}
@ -193,10 +240,10 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.clicks[id]
if !ok {
reg = clickReg{click: &gesture.Click{}}
reg = &clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
}
reg.handler = handler
r.clicks[id] = reg
// Clip to the specified region for click area
clickClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
@ -219,17 +266,101 @@ func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region,
reg.scroll.Add(gtx.Ops)
}
// PendingLongPress reports whether a press is currently held still on the
// editor (long-press armed but not yet fired). Gio renders on demand: with a
// stationary finger there are no pointer events, hence no frames, and the
// 400 ms threshold could never be checked. The main loop calls this and
// invalidates the window while it is true, keeping frames flowing until the
// long press fires or the finger moves up.
func (r *Renderer) PendingLongPress() bool {
reg, ok := r.clicks[r.longPressID]
return ok && reg.click.Pressed() && !reg.longFired && !r.ppMoved && !reg.pressAt.IsZero()
}
// CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent
for _, reg := range r.clicks {
evt, ok := reg.click.Update(q)
if ok {
if evt.Kind == gesture.KindClick {
// Long-press motion probe first: it must see the press/drag events before
// the click loop decides about a long press.
r.consumePressProbe(q)
for id, reg := range r.clicks {
// Drain every queued event for this gesture in this frame.
// gesture.Click returns one event per Update call, but on Android a
// tap's press and release routinely arrive in the same frame. Without
// draining, the release would sit unprocessed until the next redraw —
// which on an idle window may never come — and the tap is swallowed.
for {
evt, ok := reg.click.Update(q)
if !ok {
break
}
switch evt.Kind {
case gesture.KindPress:
reg.pressAt = time.Now()
reg.pressPos = evt.Position
reg.longFired = false
case gesture.KindClick:
if reg.longFired {
break // the press was consumed as a long press
}
if evt.NumClicks >= 2 {
events = append(events, InputEvent{
Handler: reg.handler,
Data: DoubleTapPoint{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
})
} else {
events = append(events, InputEvent{
Handler: reg.handler,
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
})
}
case gesture.KindCancel:
reg.pressAt = time.Time{}
reg.longFired = false
}
}
// Long press: the finger must still be down on the probed (editor)
// region, held still, for the long-press duration.
if id == r.longPressID && reg.click.Pressed() && !reg.longFired &&
!r.ppMoved && !reg.pressAt.IsZero() && time.Since(reg.pressAt) >= longPressDuration {
reg.longFired = true
events = append(events, InputEvent{
Handler: reg.handler,
Data: LongPressPoint{X: r.toDp(Px(reg.pressPos.X)), Y: r.toDp(Px(reg.pressPos.Y))},
})
}
}
// Selection / caret drags before scroll: a handle grab must win over fling.
drags := [4]*gesture.Drag{&r.selDragStart, &r.selDragEnd, &r.selDragBody, &r.selDragCaret}
for which, d := range drags {
if !r.selDragsOn[which] || r.selDragHandler == nil {
continue
}
// wasDragging is captured before Update: Update resets Dragging() on
// Release/Cancel, so it would read false afterwards.
wasDragging := d.Dragging()
// Drain all queued events for this drag in this frame (same
// one-event-per-Update rationale as the click loop above).
for {
e, ok := d.Update(m, q, gesture.Both)
if !ok {
break
}
switch e.Kind {
case pointer.Drag:
r.selDragEmitting[which] = true
events = append(events, InputEvent{
Handler: reg.handler,
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
Handler: r.selDragHandler,
Data: SelectionDragEvent{Which: which, X: r.toDp(Px(e.Position.X)), Y: r.toDp(Px(e.Position.Y))},
})
case pointer.Release, pointer.Cancel:
if wasDragging && r.selDragEmitting[which] {
events = append(events, InputEvent{
Handler: r.selDragHandler,
Data: SelectionDragEnd{},
})
}
r.selDragEmitting[which] = false
}
}
}
@ -249,6 +380,38 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
return events
}
// consumePressProbe drains the raw pointer events of the long-press probe
// and updates ppLast/ppMoved. It runs before the click loop each frame.
func (r *Renderer) consumePressProbe(q input.Source) {
for {
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel})
if !ok {
return
}
pe, ok := evt.(pointer.Event)
if !ok {
continue
}
switch pe.Kind {
case pointer.Press:
r.ppLast = pe.Position
r.ppActive = true
r.ppMoved = false
case pointer.Drag:
if r.ppActive {
dx, dy := pe.Position.X-r.ppLast.X, pe.Position.Y-r.ppLast.Y
if dx*dx+dy*dy > float32(longPressSlopPx*longPressSlopPx) {
r.ppMoved = true
}
}
r.ppLast = pe.Position
case pointer.Release, pointer.Cancel:
r.ppMoved = false
r.ppActive = false
}
}
}
// DisplayLineCount returns the number of display lines from the last
// drawWrappedText call. Used by the main loop to report back to logic.
func (r *Renderer) DisplayLineCount() int {
@ -321,12 +484,17 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = clickReg{click: &gesture.Click{}}
reg = &clickReg{click: &gesture.Click{}}
r.clicks[interactive.ID()] = reg
}
reg.handler = interaction.Handler
r.clicks[interactive.ID()] = reg
reg.click.Add(gtx.Ops)
}
if interaction.Gesture == SelDrag {
// Store the logic handler; the drag ops themselves are added
// clipped in drawWrappedText where the handle geometry is known.
r.registerInteraction(interactive.ID(), interaction, gtx)
}
}
}
e.Draw(gtx, r)
@ -352,10 +520,10 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = clickReg{click: &gesture.Click{}}
reg = &clickReg{click: &gesture.Click{}}
r.clicks[interactive.ID()] = reg
}
reg.handler = interaction.Handler
r.clicks[interactive.ID()] = reg
}
// For non-text elements, register click immediately
if _, isLabel := e.(Label); !isLabel {
@ -482,7 +650,7 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
// detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing.
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int) {
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool) {
if str == "" {
return
}
@ -620,6 +788,96 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
}
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
// Long-press probe and selection/caret drag handles. The probe op and the
// (clipped) drag registrations live in the text clip so they only respond
// inside the editor region.
r.selDragsOn = [4]bool{}
event.Op(gtx.Ops, r.pressProbe)
r.longPressID = "editor_text"
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
lineH := Dp(r.theme.FontSize) * 1.2
// handleAt mirrors the cursor computation above: window-relative byte
// offset -> screen Dp of the caret insertion point.
handleAt := func(byteOff int) (x, y Dp) {
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
return layout.ByteOffsets[i] >= byteOff
})
if idx < len(layout.ByteOffsets) {
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
}
n := len(layout.X) - 1
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - Dp(r.theme.FontSize)
}
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
hc := clip.Rect{
Min: image.Point{X: int(r.toPx(hx - 8)), Y: int(r.toPx(hy - 6))},
Max: image.Point{X: int(r.toPx(hx + 8)), Y: int(r.toPx(hy + lineH + 6))},
}.Push(gtx.Ops)
d.Add(gtx.Ops)
hc.Pop()
}
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: 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
hasGlyph := false
for i := range layout.ByteOffsets {
b0 := layout.ByteOffsets[i]
b1 := len(str)
if i+1 < len(layout.ByteOffsets) {
b1 = layout.ByteOffsets[i+1]
}
if b0 >= selEnd || b1 <= selStart {
continue
}
gx := reg.X + layout.X[i]
gy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize)
gw := layout.Advance[i]
gh := lineH
if !hasGlyph {
bx0, by0, bx1, by1 = gx, gy, gx+gw, gy+gh
hasGlyph = true
} else {
if gx < bx0 {
bx0 = gx
}
if gy < by0 {
by0 = gy
}
if gx+gw > bx1 {
bx1 = gx + gw
}
if gy+gh > by1 {
by1 = gy + gh
}
}
}
if hasGlyph {
bc := clip.Rect{
Min: image.Point{X: int(r.toPx(bx0)), Y: int(r.toPx(by0))},
Max: image.Point{X: int(r.toPx(bx1)), Y: int(r.toPx(by1))},
}.Push(gtx.Ops)
r.selDragBody.Add(gtx.Ops)
bc.Pop()
r.selDragsOn[2] = true
}
r.drawHandle(gtx, sx, sy, lineH)
r.drawHandle(gtx, ex, ey, lineH)
} else {
// Caret drag (long press on blank space): a single handle on the caret.
cx, cy := handleAt(cursorPos)
registerDrag(&r.selDragCaret, cx, cy)
r.selDragsOn[3] = true
r.drawHandle(gtx, cx, cy, lineH)
}
}
textClip.Pop()
r.displayLineCount = lineCount
// Store captured layout; derive lastLineY from it.
@ -629,6 +887,14 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
}
}
// drawHandle draws a selection handle: a short vertical stem with a filled
// square foot at the line's bottom (v1 approximation of Android's circle).
func (r *Renderer) drawHandle(gtx layout.Context, x, y, lineH Dp) {
col := Color{R: 51, G: 153, B: 255, A: 255}
r.drawBg(gtx, Region{X: x - 1, Y: y, W: 2, H: lineH}, col)
r.drawBg(gtx, Region{X: x - 5, Y: y + lineH - 2, W: 10, H: 10}, col)
}
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
if img == nil {
return