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:
parent
ec11abf8f1
commit
2a16c0017c
|
|
@ -2,15 +2,19 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/font/gofont"
|
"gioui.org/font/gofont"
|
||||||
|
"gioui.org/io/clipboard"
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/io/transfer"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/text"
|
"gioui.org/text"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
|
|
@ -97,6 +101,14 @@ func run(w *app.Window) error {
|
||||||
var searchEditor widget.Editor
|
var searchEditor widget.Editor
|
||||||
renderer.RegisterGioEditor("search_bar", &searchEditor)
|
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):
|
// frame is the frame-receiver-stored handoff (architecture.md §2.2/§9):
|
||||||
// the ONLY data the main goroutine reads from the logic side.
|
// the ONLY data the main goroutine reads from the logic side.
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
@ -134,6 +146,40 @@ func run(w *app.Window) error {
|
||||||
}
|
}
|
||||||
gtx := app.NewContext(&ops, e)
|
gtx := app.NewContext(&ops, e)
|
||||||
newScale := gtx.Metric.PxPerDp
|
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
|
// Read ONLY the frame-receiver-stored snapshot; the main goroutine
|
||||||
// never touches logic State (architecture.md §1).
|
// never touches logic State (architecture.md §1).
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
|
@ -148,6 +194,12 @@ func run(w *app.Window) error {
|
||||||
newQuery := searchEditor.Text()
|
newQuery := searchEditor.Text()
|
||||||
sendQuery := newQuery != frame.Query
|
sendQuery := newQuery != frame.Query
|
||||||
events := renderer.CheckGestures(e.Source, gtx.Metric)
|
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
|
// Gather key events
|
||||||
focusedID := frame.FocusedElementID
|
focusedID := frame.FocusedElementID
|
||||||
|
|
|
||||||
|
|
@ -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
|
before the glyphs; the IME `SelectionCmd` push dedups on the
|
||||||
(selectionStart, caret) pair.
|
(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)
|
### 6.4 IME (Android soft keyboard)
|
||||||
|
|
||||||
- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the
|
- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# Development Plan: reach a lean, usable Android text editor
|
# Development Plan: reach a lean, usable Android text editor
|
||||||
|
|
||||||
Status: v7, 2026-08-16 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping
|
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). Written
|
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
|
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
|
superseded — see §12 for why. Doc reorganization (2026-08-16): the over-detailed
|
||||||
docs (`*_implementation_plan.md`, `touch.md`, `element_model.md`,
|
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:
|
shift+arrow shows a highlight, typing replaces the selection. Remaining:
|
||||||
real-device swipe/autocorrect sign-off (the emulator's AOSP/Gboard keyboard
|
real-device swipe/autocorrect sign-off (the emulator's AOSP/Gboard keyboard
|
||||||
is a proxy).
|
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)
|
## 1. Decision summary (updated)
|
||||||
|
|
||||||
|
|
@ -339,6 +352,64 @@ real bug:
|
||||||
- Also removed the per-tap `log.Printf` debug lines in `SetCursorFromPoint`
|
- Also removed the per-tap `log.Printf` debug lines in `SetCursorFromPoint`
|
||||||
(per-tap, per-glyph logcat noise).
|
(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)
|
## 6. File-size decision (re-framed)
|
||||||
|
|
||||||
v1 framed this as "accept a limit vs build a windowed editor." The live repo
|
v1 framed this as "accept a limit vs build a windowed editor." The live repo
|
||||||
|
|
|
||||||
21
doc/spec.md
21
doc/spec.md
|
|
@ -56,11 +56,21 @@ elsewhere.
|
||||||
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
|
hardware keyboard, arrow keys, Home/End, and Page Up/Down move the cursor
|
||||||
(verified on the Android emulator; Gio's mobile focus-navigation default
|
(verified on the Android emulator; Gio's mobile focus-navigation default
|
||||||
for arrow keys is overridden — see architecture.md §2.1).
|
for arrow keys is overridden — see architecture.md §2.1).
|
||||||
- **Text selection:** shift+arrow (hardware keyboard) extends a selection;
|
- **Text selection:** two input paths. **Touch** (the Android-native model):
|
||||||
insert, backspace, and delete replace the selected text; the IME replaces
|
long-press selects the word under the finger (on a blank spot it places the
|
||||||
a selection when the user types over it. The selection is highlighted in
|
caret and offers a paste-only menu); double-tap selects the word; the
|
||||||
the editor and pushed to the IME. (Shift state is tracked by the app,
|
selection has drag handles at both ends (resize) and the highlighted body
|
||||||
since Gio's Android bridge drops modifier keys — architecture.md §2.1.)
|
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
|
- **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
|
is written to disk by a worker. Failed writes are retried. This is the only
|
||||||
persistence mechanism.
|
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. |
|
| 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. |
|
| Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. |
|
||||||
| In-file search, tabs, split view | **not implemented** | — |
|
| 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. |
|
| Files > 50 MB | **not supported** | `TooLarge` state instead. |
|
||||||
| Desktop / other platforms | **not supported** | Android-first. |
|
| Desktop / other platforms | **not supported** | Android-first. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,24 @@ func (l *Logic) SearchQueryChan() chan<- string {
|
||||||
return l.searchQueryChan
|
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 TheState *State
|
||||||
var TheLogic *Logic
|
var TheLogic *Logic
|
||||||
|
|
||||||
|
|
@ -232,6 +250,11 @@ func (l *Logic) Run() {
|
||||||
l.workerPool.DispatchNonBlocking(
|
l.workerPool.DispatchNonBlocking(
|
||||||
pool.NewWriteFileTask(l.state.Editor.Filename, content, l.mockFS),
|
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:
|
case req := <-l.inspectChan:
|
||||||
// Test-only: fn runs on the owner, preserving single ownership.
|
// Test-only: fn runs on the owner, preserving single ownership.
|
||||||
req.resp <- req.fn(l.state)
|
req.resp <- req.fn(l.state)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
|
|
@ -79,6 +80,22 @@ type EditorState struct {
|
||||||
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
// 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.
|
// It is set during layout and is what an EditEvent.Range indexes into.
|
||||||
IMEWindowText string
|
IMEWindowText 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
|
Filename string
|
||||||
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
||||||
// editor shows a "too large to edit" notice instead of content (the
|
// editor shows a "too large to edit" notice instead of content (the
|
||||||
|
|
@ -156,6 +173,17 @@ type State struct {
|
||||||
Elems []ui.Element
|
Elems []ui.Element
|
||||||
lastEvictionTime time.Time // Throttles chunk eviction
|
lastEvictionTime time.Time // Throttles chunk eviction
|
||||||
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
|
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 state (directly embedded per architecture §8)
|
||||||
Browser browser.BrowserState // Embedded, not a pointer
|
Browser browser.BrowserState // Embedded, not a pointer
|
||||||
// Editor state
|
// Editor state
|
||||||
|
|
@ -180,6 +208,9 @@ func NewState() *State {
|
||||||
writeFailed: make(map[string]bool),
|
writeFailed: make(map[string]bool),
|
||||||
retryAttempts: make(map[string]int),
|
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
|
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() {
|
func ClearSelection() {
|
||||||
e := &TheState.Editor
|
e := &TheState.Editor
|
||||||
e.SelectionAnchor = -1
|
e.SelectionAnchor = -1
|
||||||
e.SelectionStart = -1
|
e.SelectionStart = -1
|
||||||
e.SelectionEnd = -1
|
e.SelectionEnd = -1
|
||||||
|
e.MenuVisible = false
|
||||||
|
e.MenuItems = nil
|
||||||
|
e.SelDragging = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
// 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.
|
// HandleHome moves the cursor to the start of the current visual line.
|
||||||
func HandleHome() {
|
func HandleHome() {
|
||||||
layout := TheState.Editor.GlyphLayout
|
layout := TheState.Editor.GlyphLayout
|
||||||
|
|
@ -1066,6 +1564,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
||||||
W: screenWidth - margin*2,
|
W: screenWidth - margin*2,
|
||||||
H: editorH,
|
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.
|
// 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.
|
// 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.
|
// 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{
|
[]ui.Interaction{
|
||||||
{Gesture: ui.Scroll, Handler: HandleScroll},
|
{Gesture: ui.Scroll, Handler: HandleScroll},
|
||||||
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
{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) {
|
{Gesture: ui.Tap, Handler: func(data any) {
|
||||||
// Swallow the tap that opened the file. Its gesture belongs to the
|
// The just-opened guard (swallowing the tap that opened the file)
|
||||||
// browser row we just left and must not position the cursor in the
|
// lives inside the Handle* functions.
|
||||||
// editor (a deliberate tap-to-position happens later, well past this
|
switch pt := data.(type) {
|
||||||
// window).
|
case ui.Point:
|
||||||
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
HandleTapAt(pt.X, pt.Y)
|
||||||
return
|
case ui.DoubleTapPoint:
|
||||||
}
|
HandleDoubleTapAt(pt.X, pt.Y)
|
||||||
if pt, ok := data.(ui.Point); ok {
|
case ui.LongPressPoint:
|
||||||
// Window-space tap -> window-relative text-local coordinates.
|
HandleLongPressAt(pt.X, pt.Y)
|
||||||
// (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)
|
|
||||||
}
|
}
|
||||||
}},
|
}},
|
||||||
|
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
||||||
// for Gio to deliver key events to this element.
|
// for Gio to deliver key events to this element.
|
||||||
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
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
|
// 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()))
|
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) {
|
func SetCursorFromPoint(x, y float64) {
|
||||||
// A tap is an explicit cursor placement: it always clears any selection.
|
|
||||||
ClearSelection()
|
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
|
layout := TheState.Editor.GlyphLayout
|
||||||
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
||||||
return
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
base := glyphBase()
|
base := glyphBase()
|
||||||
|
|
@ -1329,7 +1853,7 @@ func SetCursorFromPoint(x, y float64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(groups[visualLine].indices) == 0 {
|
if len(groups[visualLine].indices) == 0 {
|
||||||
return
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
targetGroup := groups[visualLine]
|
targetGroup := groups[visualLine]
|
||||||
|
|
@ -1355,7 +1879,7 @@ func SetCursorFromPoint(x, y float64) {
|
||||||
content, err := buf.FullContent()
|
content, err := buf.FullContent()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error getting full content: %v", err)
|
log.Printf("Error getting full content: %v", err)
|
||||||
return
|
return 0, false
|
||||||
}
|
}
|
||||||
fileContent = content
|
fileContent = content
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1363,11 +1887,10 @@ func SetCursorFromPoint(x, y float64) {
|
||||||
}
|
}
|
||||||
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
||||||
if r == '\n' {
|
if r == '\n' {
|
||||||
TheState.Editor.CursorPosition = start
|
return start, true
|
||||||
} else {
|
} else {
|
||||||
TheState.Editor.CursorPosition = start + size
|
return start + size, true
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Otherwise, find the closest glyph on this line.
|
// 5. Otherwise, find the closest glyph on this line.
|
||||||
|
|
@ -1389,6 +1912,7 @@ func SetCursorFromPoint(x, y float64) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if bestIdx != -1 {
|
if bestIdx != -1 {
|
||||||
TheState.Editor.CursorPosition = base + layout.ByteOffsets[bestIdx]
|
return base + layout.ByteOffsets[bestIdx], true
|
||||||
}
|
}
|
||||||
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
368
internal/editor/touch_selection_test.go
Normal file
368
internal/editor/touch_selection_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
207
internal/test/e2e/touch_selection_e2e_test.go
Normal file
207
internal/test/e2e/touch_selection_e2e_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -192,6 +192,9 @@ type TextField struct {
|
||||||
// in-app highlight.
|
// in-app highlight.
|
||||||
SelectionStart int
|
SelectionStart int
|
||||||
SelectionEnd int
|
SelectionEnd int
|
||||||
|
// 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
|
ScrollOffset Dp
|
||||||
VisibleLines []Line
|
VisibleLines []Line
|
||||||
WordWrap bool
|
WordWrap bool
|
||||||
|
|
@ -290,7 +293,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
||||||
r.lastSelStart = -1
|
r.lastSelStart = -1
|
||||||
r.lastSelCaret = -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
|
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
|
||||||
|
|
@ -849,6 +852,10 @@ const (
|
||||||
Scroll
|
Scroll
|
||||||
KeyDown
|
KeyDown
|
||||||
KeyUp
|
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.
|
// Interaction pairs a gesture type with a handler function.
|
||||||
|
|
@ -877,6 +884,86 @@ type Point struct {
|
||||||
X, Y Dp
|
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.
|
// InputEvent represents a user input event with its handler.
|
||||||
type InputEvent struct {
|
type InputEvent struct {
|
||||||
Handler func(any)
|
Handler func(any)
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,21 @@ var iconFS embed.FS
|
||||||
type clickReg struct {
|
type clickReg struct {
|
||||||
click *gesture.Click
|
click *gesture.Click
|
||||||
handler func(any)
|
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.
|
// keyReg pairs a handler for key events.
|
||||||
type keyReg struct {
|
type keyReg struct {
|
||||||
Handler func(any)
|
Handler func(any)
|
||||||
|
|
@ -59,7 +72,7 @@ type Renderer struct {
|
||||||
shp *text.Shaper
|
shp *text.Shaper
|
||||||
scale float32 // px-per-Dp for the current draw pass; set in Draw
|
scale float32 // px-per-Dp for the current draw pass; set in Draw
|
||||||
icons map[string]image.Image
|
icons map[string]image.Image
|
||||||
clicks map[string]clickReg
|
clicks map[string]*clickReg
|
||||||
Keys map[string]keyReg // Exported Keys map
|
Keys map[string]keyReg // Exported Keys map
|
||||||
scrolls map[string]scrollReg
|
scrolls map[string]scrollReg
|
||||||
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
|
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
|
||||||
|
|
@ -77,6 +90,35 @@ type Renderer struct {
|
||||||
lastSnippet key.Snippet
|
lastSnippet key.Snippet
|
||||||
lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection
|
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
|
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.
|
// New creates a new Renderer.
|
||||||
|
|
@ -85,7 +127,7 @@ func New(th Theme, shp *text.Shaper) *Renderer {
|
||||||
theme: th,
|
theme: th,
|
||||||
shp: shp,
|
shp: shp,
|
||||||
icons: make(map[string]image.Image),
|
icons: make(map[string]image.Image),
|
||||||
clicks: make(map[string]clickReg),
|
clicks: make(map[string]*clickReg),
|
||||||
Keys: make(map[string]keyReg),
|
Keys: make(map[string]keyReg),
|
||||||
scrolls: make(map[string]scrollReg),
|
scrolls: make(map[string]scrollReg),
|
||||||
gioEditors: make(map[string]*widget.Editor),
|
gioEditors: make(map[string]*widget.Editor),
|
||||||
|
|
@ -169,10 +211,10 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
||||||
case Tap:
|
case Tap:
|
||||||
reg, ok := r.clicks[id]
|
reg, ok := r.clicks[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
reg = clickReg{click: &gesture.Click{}}
|
reg = &clickReg{click: &gesture.Click{}}
|
||||||
|
r.clicks[id] = reg
|
||||||
}
|
}
|
||||||
reg.handler = interaction.Handler
|
reg.handler = interaction.Handler
|
||||||
r.clicks[id] = reg
|
|
||||||
// Register click within current clip context
|
// Register click within current clip context
|
||||||
reg.click.Add(gtx.Ops)
|
reg.click.Add(gtx.Ops)
|
||||||
case Scroll:
|
case Scroll:
|
||||||
|
|
@ -184,6 +226,11 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
||||||
reg.scroll.Add(gtx.Ops)
|
reg.scroll.Add(gtx.Ops)
|
||||||
reg.handler = interaction.Handler
|
reg.handler = interaction.Handler
|
||||||
r.scrolls[id] = reg
|
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)) {
|
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
|
||||||
reg, ok := r.clicks[id]
|
reg, ok := r.clicks[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
reg = clickReg{click: &gesture.Click{}}
|
reg = &clickReg{click: &gesture.Click{}}
|
||||||
|
r.clicks[id] = reg
|
||||||
}
|
}
|
||||||
reg.handler = handler
|
reg.handler = handler
|
||||||
r.clicks[id] = reg
|
|
||||||
// Clip to the specified region for click area
|
// Clip to the specified region for click area
|
||||||
clickClip := clip.Rect{
|
clickClip := clip.Rect{
|
||||||
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
|
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
|
||||||
|
|
@ -219,18 +266,102 @@ func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region,
|
||||||
reg.scroll.Add(gtx.Ops)
|
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.
|
// CheckGestures checks all registered gestures and returns any events.
|
||||||
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||||
var events []InputEvent
|
var events []InputEvent
|
||||||
for _, reg := range r.clicks {
|
// 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)
|
evt, ok := reg.click.Update(q)
|
||||||
if ok {
|
if !ok {
|
||||||
if evt.Kind == gesture.KindClick {
|
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{
|
events = append(events, InputEvent{
|
||||||
Handler: reg.handler,
|
Handler: reg.handler,
|
||||||
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
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: 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, reg := range r.scrolls {
|
for _, reg := range r.scrolls {
|
||||||
|
|
@ -249,6 +380,38 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||||
return events
|
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
|
// DisplayLineCount returns the number of display lines from the last
|
||||||
// drawWrappedText call. Used by the main loop to report back to logic.
|
// drawWrappedText call. Used by the main loop to report back to logic.
|
||||||
func (r *Renderer) DisplayLineCount() int {
|
func (r *Renderer) DisplayLineCount() int {
|
||||||
|
|
@ -321,12 +484,17 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||||||
if interaction.Gesture == Tap {
|
if interaction.Gesture == Tap {
|
||||||
reg, ok := r.clicks[interactive.ID()]
|
reg, ok := r.clicks[interactive.ID()]
|
||||||
if !ok {
|
if !ok {
|
||||||
reg = clickReg{click: &gesture.Click{}}
|
reg = &clickReg{click: &gesture.Click{}}
|
||||||
|
r.clicks[interactive.ID()] = reg
|
||||||
}
|
}
|
||||||
reg.handler = interaction.Handler
|
reg.handler = interaction.Handler
|
||||||
r.clicks[interactive.ID()] = reg
|
|
||||||
reg.click.Add(gtx.Ops)
|
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)
|
e.Draw(gtx, r)
|
||||||
|
|
@ -352,10 +520,10 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||||||
if interaction.Gesture == Tap {
|
if interaction.Gesture == Tap {
|
||||||
reg, ok := r.clicks[interactive.ID()]
|
reg, ok := r.clicks[interactive.ID()]
|
||||||
if !ok {
|
if !ok {
|
||||||
reg = clickReg{click: &gesture.Click{}}
|
reg = &clickReg{click: &gesture.Click{}}
|
||||||
|
r.clicks[interactive.ID()] = reg
|
||||||
}
|
}
|
||||||
reg.handler = interaction.Handler
|
reg.handler = interaction.Handler
|
||||||
r.clicks[interactive.ID()] = reg
|
|
||||||
}
|
}
|
||||||
// For non-text elements, register click immediately
|
// For non-text elements, register click immediately
|
||||||
if _, isLabel := e.(Label); !isLabel {
|
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.
|
// detection via WrapHeuristically. Long words overflow the wrap width.
|
||||||
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
||||||
// of glyph metrics. The shaper's first.Y accounts for line spacing.
|
// 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 == "" {
|
if str == "" {
|
||||||
return
|
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})
|
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()
|
textClip.Pop()
|
||||||
r.displayLineCount = lineCount
|
r.displayLineCount = lineCount
|
||||||
// Store captured layout; derive lastLineY from it.
|
// 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) {
|
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
|
||||||
if img == nil {
|
if img == nil {
|
||||||
return
|
return
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user