From 2a16c0017c9f59a715496f4557790b6e4ffee0fe Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Mon, 17 Aug 2026 08:57:55 -0400 Subject: [PATCH] Touch selection (v1): long-press/double-tap word selection, drag handles, floating copy/cut/paste menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/pad/main.go | 52 ++ doc/architecture.md | 57 ++ doc/development_plan.md | 75 ++- doc/spec.md | 21 +- internal/editor/logic.go | 23 + internal/editor/state.go | 576 +++++++++++++++++- internal/editor/touch_selection_test.go | 368 +++++++++++ internal/test/e2e/touch_selection_e2e_test.go | 207 +++++++ internal/ui/element.go | 97 ++- internal/ui/render.go | 300 ++++++++- 10 files changed, 1720 insertions(+), 56 deletions(-) create mode 100644 internal/editor/touch_selection_test.go create mode 100644 internal/test/e2e/touch_selection_e2e_test.go diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 5fd3459..ab048cb 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 diff --git a/doc/architecture.md b/doc/architecture.md index b5ce425..68e3e98 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -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 diff --git a/doc/development_plan.md b/doc/development_plan.md index a7e2c7c..1f14828 100644 --- a/doc/development_plan.md +++ b/doc/development_plan.md @@ -1,7 +1,8 @@ # Development Plan: reach a lean, usable Android text editor -Status: v7, 2026-08-16 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping -verification + tap-to-position-cursor fix + selection + real-file e2e). Written +Status: v8, 2026-08-17 (Phases 0–3 + 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 diff --git a/doc/spec.md b/doc/spec.md index 62b00ac..ee6efad 100644 --- a/doc/spec.md +++ b/doc/spec.md @@ -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. | diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 4485789..27dd0da 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -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) diff --git a/internal/editor/state.go b/internal/editor/state.go index 9236596..25af123 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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 } diff --git a/internal/editor/touch_selection_test.go b/internal/editor/touch_selection_test.go new file mode 100644 index 0000000..76c0b4f --- /dev/null +++ b/internal/editor/touch_selection_test.go @@ -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") + } +} diff --git a/internal/test/e2e/touch_selection_e2e_test.go b/internal/test/e2e/touch_selection_e2e_test.go new file mode 100644 index 0000000..d91f9e9 --- /dev/null +++ b/internal/test/e2e/touch_selection_e2e_test.go @@ -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) + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index bd6664e..8dad62d 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -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) diff --git a/internal/ui/render.go b/internal/ui/render.go index b122bc5..1c023df 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -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