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 (
|
||||
"flag"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/font/gofont"
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/io/key"
|
||||
"gioui.org/io/transfer"
|
||||
"gioui.org/op"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
|
|
@ -97,6 +101,14 @@ func run(w *app.Window) error {
|
|||
var searchEditor widget.Editor
|
||||
renderer.RegisterGioEditor("search_bar", &searchEditor)
|
||||
|
||||
// Clipboard plumbing (architecture.md §6.3): the logic goroutine only
|
||||
// REQUESTS clipboard operations through channels (copy/cut write, paste
|
||||
// read); the main goroutine executes the Gio ops during a frame and
|
||||
// forwards the read result back. clipTag tags the read so its
|
||||
// transfer.DataEvent can be matched. A nil clipboard (e.g. a headless
|
||||
// test) simply never completes a read.
|
||||
var clipTag = struct{}{}
|
||||
|
||||
// frame is the frame-receiver-stored handoff (architecture.md §2.2/§9):
|
||||
// the ONLY data the main goroutine reads from the logic side.
|
||||
var mu sync.Mutex
|
||||
|
|
@ -134,6 +146,40 @@ func run(w *app.Window) error {
|
|||
}
|
||||
gtx := app.NewContext(&ops, e)
|
||||
newScale := gtx.Metric.PxPerDp
|
||||
// Clipboard: forward a read result from an earlier frame to the
|
||||
// logic goroutine (one DataEvent per ReadCmd).
|
||||
for {
|
||||
evt, ok := gtx.Event(transfer.TargetFilter{Target: clipTag, Type: "application/text"})
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if de, ok := evt.(transfer.DataEvent); ok {
|
||||
body := de.Open()
|
||||
data, rerr := io.ReadAll(body)
|
||||
body.Close()
|
||||
if rerr == nil {
|
||||
logic.PasteChan() <- string(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clipboard: a copy/cut write requested by the logic goroutine.
|
||||
select {
|
||||
case t := <-logic.ClipboardSetChan():
|
||||
gtx.Execute(clipboard.WriteCmd{Type: "application/text", Data: io.NopCloser(strings.NewReader(t))})
|
||||
default:
|
||||
}
|
||||
// Clipboard: a paste request (one ReadCmd per request; the result
|
||||
// arrives as a DataEvent on a later frame). On Android the read is
|
||||
// synchronous and the resulting DataEvent is queued during this
|
||||
// frame's op flush — but a queued DataEvent schedules no wakeup of
|
||||
// its own, so invalidate to guarantee a follow-up frame in which
|
||||
// the loop above can consume it.
|
||||
select {
|
||||
case <-logic.PasteReqChan():
|
||||
gtx.Execute(clipboard.ReadCmd{Tag: clipTag})
|
||||
w.Invalidate()
|
||||
default:
|
||||
}
|
||||
// Read ONLY the frame-receiver-stored snapshot; the main goroutine
|
||||
// never touches logic State (architecture.md §1).
|
||||
mu.Lock()
|
||||
|
|
@ -148,6 +194,12 @@ func run(w *app.Window) error {
|
|||
newQuery := searchEditor.Text()
|
||||
sendQuery := newQuery != frame.Query
|
||||
events := renderer.CheckGestures(e.Source, gtx.Metric)
|
||||
// Keep frames flowing while a long press is pending: a stationary
|
||||
// finger generates no pointer events, so without this the window
|
||||
// would sleep and the long-press threshold would never be reached.
|
||||
if renderer.PendingLongPress() {
|
||||
w.Invalidate()
|
||||
}
|
||||
|
||||
// Gather key events
|
||||
focusedID := frame.FocusedElementID
|
||||
|
|
|
|||
|
|
@ -270,6 +270,63 @@ Only the visible byte range is shaped and drawn each frame:
|
|||
before the glyphs; the IME `SelectionCmd` push dedups on the
|
||||
(selectionStart, caret) pair.
|
||||
|
||||
### 6.3a Touch selection (v1)
|
||||
|
||||
- **Division of labor: the renderer reports finger positions, the logic owns
|
||||
all geometry.** Touch input is delivered to the logic goroutine as
|
||||
`ui.Point` (tap), `ui.DoubleTapPoint`, `ui.LongPressPoint`, and selection
|
||||
drag events (which handle + app-local Dp position). The logic converts them
|
||||
to text coordinates using the `EditorRegion` stored on `State` (set each
|
||||
layout frame) plus the scroll offset, hit-tests the floating-menu items
|
||||
itself, and owns the menu rect, highlight range, and handle positions.
|
||||
The renderer only draws what the `Frame` snapshot says (menu, handles,
|
||||
highlight) and registers the input regions.
|
||||
- **Menu taps are logic-decided.** The menu panel is one `Tap` interaction
|
||||
over its whole rect; the editor's tap handler ignores any tap inside a
|
||||
visible menu (`pointInMenu` guard) so both handlers can coexist regardless
|
||||
of dispatch order. Item identity comes from the tap's X within the panel.
|
||||
- **Long press needs frames to elapse.** Gio renders on demand; a stationary
|
||||
finger produces no pointer events and therefore no frames, so the 400 ms
|
||||
threshold could never be checked. The main loop polls
|
||||
`Renderer.PendingLongPress()` and keeps invalidating the window while a
|
||||
press is held still on the editor. A non-grabbing raw pointer probe
|
||||
(plain `event.Op` tag on the editor region) observes the press's motion and
|
||||
cancels the pending long press on movement; a scroll or handle drag grabs
|
||||
the pointer (`pointer.GrabCmd`), which cancels it via `pointer.Cancel`.
|
||||
- **A pointer filter with zero `Kinds` matches nothing.**
|
||||
`pointer.Filter.Matches` tests `e.Kind & f.Kinds == e.Kind`, so a query
|
||||
without `Kinds` silently receives no pointer events — probe queries must
|
||||
name the kinds they want.
|
||||
- **One event per Update is a trap on Android.** A tap's down+up routinely
|
||||
arrive in a *single* frame, and `gesture.Click`/`gesture.Drag` return at
|
||||
most one event per `Update` call. If the renderer processed only one event
|
||||
per gesture per frame, the release would sit in the queue until the next
|
||||
redraw — which on an idle window may never come — and the tap is swallowed
|
||||
(this is why menu taps initially required a second tap to "rescue" the
|
||||
first). The renderer therefore drains each click/drag gesture's queue to
|
||||
exhaustion every frame; `gesture.Scroll` already drains internally.
|
||||
- **Per-frame gesture bookkeeping must survive the frame.** Click-registry
|
||||
state (press time/position, long-press-fired flag) is kept in structs held
|
||||
**by pointer** in a map; `range` over a value-type map yields copies and
|
||||
silently discards the mutations.
|
||||
- **Clipboard crosses the goroutine boundary via channels; the ops run on
|
||||
the main/Gio frame path.** Logic→main: `clipboardSetChan` (string to write)
|
||||
and `pasteReqChan` (token). Main→logic: `pasteChan` (string). All three are
|
||||
buffered (16) so a harness with no main loop can never block the logic
|
||||
goroutine. Main executes `clipboard.WriteCmd` / `clipboard.ReadCmd` during
|
||||
a frame and forwards read results back on `pasteChan` from the
|
||||
`transfer.DataEvent`.
|
||||
- **Android clipboard reads need an explicit invalidation.** Gio v0.10 on
|
||||
Android answers `ReadCmd` synchronously during the op flush by queueing a
|
||||
`transfer.DataEvent`; a queued DataEvent schedules **no** frame wakeup of
|
||||
its own. Main therefore invalidates the window after each read so a
|
||||
follow-up frame exists in which the DataEvent is consumed.
|
||||
- **The menu closes on any item tap.** Copy keeps the selection (only the
|
||||
menu disappears); cut removes it (via `ClearSelection`, which also hides
|
||||
the menu and cancels drag bookkeeping); paste closes the menu immediately
|
||||
even though the actual insert happens on a later frame when the clipboard
|
||||
content arrives.
|
||||
|
||||
### 6.4 IME (Android soft keyboard)
|
||||
|
||||
- The editor exposes to the IME a **windowed snippet**: `IMEWindowText` is the
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# Development Plan: reach a lean, usable Android text editor
|
||||
|
||||
Status: v7, 2026-08-16 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping
|
||||
verification + tap-to-position-cursor fix + selection + real-file e2e). Written
|
||||
Status: v8, 2026-08-17 (Phases 0–3 + doc reorg + Phase 6 scroll-perf/clamping
|
||||
verification + tap-to-position-cursor fix + selection + real-file e2e +
|
||||
Android arrow/shift workaround + touch selection). Written
|
||||
against the **live** repo `/home/gmp/pad`. v1 (the widget-rebuild plan) is
|
||||
superseded — see §12 for why. Doc reorganization (2026-08-16): the over-detailed
|
||||
docs (`*_implementation_plan.md`, `touch.md`, `element_model.md`,
|
||||
|
|
@ -44,6 +45,18 @@ Verified end-to-end on the emulator: plain arrows move the caret,
|
|||
shift+arrow shows a highlight, typing replaces the selection. Remaining:
|
||||
real-device swipe/autocorrect sign-off (the emulator's AOSP/Gboard keyboard
|
||||
is a proxy).
|
||||
Phase 9 (2026-08-17) added **touch selection** — the Android-native selection
|
||||
model: long-press selects the word under the finger (blank → caret + paste-only
|
||||
menu), double-tap selects the word, drag handles resize the selection, drag the
|
||||
highlighted body moves it, and a floating copy/cut/paste menu is hit-tested in
|
||||
logic and drawn by the renderer. All flows verified on-device, including the
|
||||
full copy→paste and cut cycles through the real Android clipboard. Debugging
|
||||
this surfaced two renderer bugs worth knowing about: (1) a map of click-registry
|
||||
**values** (not pointers) silently discarded per-frame mutations, so the
|
||||
long-press never fired; (2) on Android a tap's press and release arrive in the
|
||||
**same frame**, and `gesture.Click`/`gesture.Drag` return one event per Update
|
||||
call, so without draining each gesture's queue every frame the release was lost
|
||||
on an idle window and every menu tap was swallowed.
|
||||
|
||||
## 1. Decision summary (updated)
|
||||
|
||||
|
|
@ -339,6 +352,64 @@ real bug:
|
|||
- Also removed the per-tap `log.Printf` debug lines in `SetCursorFromPoint`
|
||||
(per-tap, per-glyph logcat noise).
|
||||
|
||||
### Phase 8 — selection on-device + Gio Android key limitation — DONE (2026-08-17)
|
||||
|
||||
Verified shift+arrow selection on the emulator, then root-caused why plain
|
||||
hardware arrows never reached the editor on Android (see the Phase 8 note in the
|
||||
status header): Gio v0.10's Android JNI drops modifier state, and plain arrow
|
||||
*presses* are wrapped in `input.SystemEvent` for focus navigation. Fix in
|
||||
main.go: explicit named `key.Filter`s for the four arrows (delivers the press
|
||||
and suppresses the focus jump) plus app-side shift tracking (`NameShift`
|
||||
press/release, reset on focus loss); forwarded events carry
|
||||
`Shift: Modifiers.Contain(ModShift) || shiftDown`. Verified on-device: `keyevent
|
||||
22` moves the caret, `keycombination 59 22` extends the selection, typing
|
||||
replaces it. Documented as a Gio-version-sensitive workaround (architecture.md
|
||||
§2.1).
|
||||
|
||||
### Phase 9 — touch selection — DONE (2026-08-17)
|
||||
|
||||
Implemented the Android-native touch selection model (v1 scope: word selection
|
||||
+ resize + move + floating menu; no handle-tap-to-caret, no marquee, no
|
||||
auto-scroll-to-cursor). See spec.md §2.2 and architecture.md §6.3 for the
|
||||
contracts; the highlights:
|
||||
|
||||
- **Renderer reports finger positions, logic owns all geometry.** Touch events
|
||||
are delivered as `ui.Point` (tap), `ui.DoubleTapPoint`, `ui.LongPressPoint`
|
||||
(app-local Dp); the logic goroutine converts them to text coordinates with
|
||||
the stored `EditorRegion` + scroll offset, hit-tests menu items itself, and
|
||||
owns the menu rect, highlight range, and handle positions. The renderer only
|
||||
draws what the frame snapshot says.
|
||||
- **Long press needs invalidation to elapse.** Gio renders on demand; a
|
||||
stationary finger produces no events and no frames, so the 400 ms threshold
|
||||
could never be checked. The main loop polls `Renderer.PendingLongPress()`
|
||||
and keeps `w.Invalidate()`-ing while a press is held still on the editor.
|
||||
A non-grabbing raw pointer probe (`event.Op` tag) watches the press for
|
||||
movement so a scroll cancels the pending long press; scroll/drag gestures
|
||||
grab the pointer (`pointer.GrabCmd`) and cancel it the same way.
|
||||
- **One event per Update is a trap on Android.** A tap's down+up routinely
|
||||
land in one frame; `gesture.Click`/`gesture.Drag` return at most one event
|
||||
per `Update` call, so the release sat in the queue until the *next* redraw —
|
||||
which on an idle window never comes. The renderer now drains each gesture's
|
||||
queue to exhaustion every frame (scroll already drains internally).
|
||||
- **Clipboard crosses the goroutine boundary via channels; the ops run on the
|
||||
main/Gio frame path** (`clipboardSetChan`/`pasteReqChan` logic→main,
|
||||
`pasteChan` main→logic, all buffered so the harness never blocks logic).
|
||||
On Android, `clipboard.ReadCmd` is answered synchronously during op flush
|
||||
with a queued `transfer.DataEvent` that schedules **no** frame of its own,
|
||||
so main invalidates the window after each read to guarantee a follow-up
|
||||
frame in which the DataEvent is consumed and forwarded to logic.
|
||||
- **Bugs found while validating on-device:** `clickReg` stored by value in a
|
||||
map (per-frame press bookkeeping lost → long press never fired); `Menu.Draw`
|
||||
didn't offset items by the menu origin; the clippable `drawElement` branch
|
||||
skipped `SelDrag` registration (handles never drew); the press-probe query
|
||||
omitted `Kinds` (a `pointer.Filter` with zero kinds matches nothing).
|
||||
- **Verification:** unit tests (`touch_selection_test.go`, ~18) + e2e
|
||||
(`touch_selection_e2e_test.go`) green under `-race`; on-device: long-press
|
||||
word/blank, double-tap, handle-drag resize, single-tap copy/cut/paste
|
||||
(including copy→paste and cut cycles through the real Android clipboard),
|
||||
menu close-on-tap, plain-tap caret placement, and scroll-drag not firing a
|
||||
long press.
|
||||
|
||||
## 6. File-size decision (re-framed)
|
||||
|
||||
v1 framed this as "accept a limit vs build a windowed editor." The live repo
|
||||
|
|
|
|||
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
|
||||
(verified on the Android emulator; Gio's mobile focus-navigation default
|
||||
for arrow keys is overridden — see architecture.md §2.1).
|
||||
- **Text selection:** shift+arrow (hardware keyboard) extends a selection;
|
||||
insert, backspace, and delete replace the selected text; the IME replaces
|
||||
a selection when the user types over it. The selection is highlighted in
|
||||
the editor and pushed to the IME. (Shift state is tracked by the app,
|
||||
since Gio's Android bridge drops modifier keys — architecture.md §2.1.)
|
||||
- **Text selection:** two input paths. **Touch** (the Android-native model):
|
||||
long-press selects the word under the finger (on a blank spot it places the
|
||||
caret and offers a paste-only menu); double-tap selects the word; the
|
||||
selection has drag handles at both ends (resize) and the highlighted body
|
||||
can be dragged to move it (length preserved); a floating menu offers
|
||||
copy/cut/paste when a selection is active and paste alone for a bare caret.
|
||||
Any menu tap closes the menu; copy keeps the selection, cut removes it, and
|
||||
paste inserts at the caret or replaces the selection. A plain tap still
|
||||
places the caret and clears any selection; taps inside the visible menu are
|
||||
ignored by the editor. **Hardware keyboard:** shift+arrow extends a
|
||||
selection. In both cases insert, backspace, and delete replace the selected
|
||||
text, and the IME replaces a selection when the user types over it. The
|
||||
selection is highlighted in the editor and pushed to the IME. (Shift state
|
||||
is tracked by the app, since Gio's Android bridge drops modifier keys —
|
||||
architecture.md §2.1.)
|
||||
- **Autosave:** every edit restarts a 1 s debounce; on expiry the full content
|
||||
is written to disk by a worker. Failed writes are retried. This is the only
|
||||
persistence mechanism.
|
||||
|
|
@ -154,7 +164,6 @@ recorded here so future rounds don't mistake doc text for behavior:
|
|||
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |
|
||||
| Alphabetical index sidebar | **not implemented** | `AlphaIndex` element exists but is unused. |
|
||||
| In-file search, tabs, split view | **not implemented** | — |
|
||||
| Touch selection (long-press, drag handles) | **not implemented** | Selection is reachable only via hardware-keyboard shift+arrow; touch users can position the caret by tap. |
|
||||
| Files > 50 MB | **not supported** | `TooLarge` state instead. |
|
||||
| Desktop / other platforms | **not supported** | Android-first. |
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,24 @@ func (l *Logic) SearchQueryChan() chan<- string {
|
|||
return l.searchQueryChan
|
||||
}
|
||||
|
||||
// ClipboardSetChan returns the channel the logic goroutine uses to request a
|
||||
// system clipboard write; the main goroutine executes the Gio clipboard op.
|
||||
func (l *Logic) ClipboardSetChan() <-chan string {
|
||||
return l.state.clipboardSetChan
|
||||
}
|
||||
|
||||
// PasteReqChan returns the channel the logic goroutine uses to request the
|
||||
// system clipboard content for paste.
|
||||
func (l *Logic) PasteReqChan() <-chan struct{} {
|
||||
return l.state.pasteReqChan
|
||||
}
|
||||
|
||||
// PasteChan delivers system clipboard content to the logic goroutine for
|
||||
// paste (tests use it to inject clipboard text without a main loop).
|
||||
func (l *Logic) PasteChan() chan<- string {
|
||||
return l.state.pasteChan
|
||||
}
|
||||
|
||||
var TheState *State
|
||||
var TheLogic *Logic
|
||||
|
||||
|
|
@ -232,6 +250,11 @@ func (l *Logic) Run() {
|
|||
l.workerPool.DispatchNonBlocking(
|
||||
pool.NewWriteFileTask(l.state.Editor.Filename, content, l.mockFS),
|
||||
)
|
||||
case p := <-l.state.pasteChan:
|
||||
// Clipboard content arrived from the main goroutine: insert it
|
||||
// (replacing any live selection, per the selection-aware edit rule).
|
||||
HandlePaste(p)
|
||||
l.emitFrame()
|
||||
case req := <-l.inspectChan:
|
||||
// Test-only: fn runs on the owner, preserving single ownership.
|
||||
req.resp <- req.fn(l.state)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gioui.org/io/key"
|
||||
|
|
@ -79,7 +80,23 @@ type EditorState struct {
|
|||
// IMEWindowText is the visible window text shown to the IME (the snippet).
|
||||
// It is set during layout and is what an EditEvent.Range indexes into.
|
||||
IMEWindowText string
|
||||
Filename string
|
||||
// --- Touch selection (v1) ---
|
||||
// CaretDrag: after a long press on blank space a single draggable caret
|
||||
// handle is shown (no selection). MenuVisible/MenuRect/MenuItems: the
|
||||
// floating copy/cut/paste menu (positioned below the line of the selection
|
||||
// end; items are recomputed when the menu is shown). SelDragging/
|
||||
// SelDragWhich/SelDragRel: transient state of an in-progress handle/body
|
||||
// drag (Which: 0 = start handle, 1 = end handle, 2 = body, 3 = caret
|
||||
// handle). All of it is logic-owned; the renderer only reports finger
|
||||
// positions and draws the geometry.
|
||||
CaretDrag bool
|
||||
MenuVisible bool
|
||||
MenuRect ui.Region
|
||||
MenuItems []ui.MenuItem
|
||||
SelDragging bool
|
||||
SelDragWhich int
|
||||
SelDragRel int
|
||||
Filename string
|
||||
// TooLarge is set when an opened file exceeds MaxEditableFileSize. The
|
||||
// editor shows a "too large to edit" notice instead of content (the
|
||||
// browser can still list the file).
|
||||
|
|
@ -156,6 +173,17 @@ type State struct {
|
|||
Elems []ui.Element
|
||||
lastEvictionTime time.Time // Throttles chunk eviction
|
||||
justOpenedAt time.Time // when the editor page was last opened; used to swallow the opening tap
|
||||
// EditorRegion is the editor text region in app-local Dp, recomputed on
|
||||
// every layout. Input handlers (tap, long press, selection drags) convert
|
||||
// app-local points to text-local coordinates through it.
|
||||
EditorRegion ui.Region
|
||||
// Clipboard channels (touch-selection menu). ClipboardSetChan and
|
||||
// PasteReqChan are consumed by the main goroutine, which executes the Gio
|
||||
// clipboard ops and reports the read result back on PasteChan. Buffered so
|
||||
// a harness without a main loop never blocks the logic goroutine.
|
||||
clipboardSetChan chan string
|
||||
pasteReqChan chan struct{}
|
||||
pasteChan chan string
|
||||
// Browser state (directly embedded per architecture §8)
|
||||
Browser browser.BrowserState // Embedded, not a pointer
|
||||
// Editor state
|
||||
|
|
@ -180,6 +208,9 @@ func NewState() *State {
|
|||
writeFailed: make(map[string]bool),
|
||||
retryAttempts: make(map[string]int),
|
||||
},
|
||||
clipboardSetChan: make(chan string, 16),
|
||||
pasteReqChan: make(chan struct{}, 16),
|
||||
pasteChan: make(chan string, 16),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -434,12 +465,18 @@ func selActive() bool {
|
|||
return e.SelectionAnchor >= 0 && e.SelectionStart >= 0 && e.SelectionEnd > e.SelectionStart
|
||||
}
|
||||
|
||||
// ClearSelection drops the selection and the shift-anchor.
|
||||
// ClearSelection drops the selection and the shift-anchor. It also dismisses
|
||||
// the selection menu and cancels any in-progress drag bookkeeping: every path
|
||||
// that clears the selection (tap, plain cursor move, any edit) should take
|
||||
// the menu and handles down with it.
|
||||
func ClearSelection() {
|
||||
e := &TheState.Editor
|
||||
e.SelectionAnchor = -1
|
||||
e.SelectionStart = -1
|
||||
e.SelectionEnd = -1
|
||||
e.MenuVisible = false
|
||||
e.MenuItems = nil
|
||||
e.SelDragging = false
|
||||
}
|
||||
|
||||
// SetSelection selects the byte range [min(start,end), max(start,end)),
|
||||
|
|
@ -531,6 +568,467 @@ func deleteRange(start, end int) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Touch selection (v1) ---------------------------------------------------
|
||||
//
|
||||
// Android-style touch interaction, layered over the byte-range selection
|
||||
// model above. Single tap = caret (existing). Long press = select the word
|
||||
// under the finger (or a draggable caret handle on blank space). Double tap
|
||||
// = select the word. Handles: drag start/end to resize, drag the body to move
|
||||
// the whole selection. Floating menu: Copy / Cut / Paste.
|
||||
//
|
||||
// Coordinate flow: the renderer reports app-local window Dp points (the same
|
||||
// space as a tap). The functions below convert to text-local coordinates via
|
||||
// EditorRegion + tapLocalY, then to byte offsets via textPosFromLocalPoint.
|
||||
// All of these run on the logic goroutine (owner).
|
||||
|
||||
const (
|
||||
menuItemW = ui.Dp(56) // width of one selection-menu button
|
||||
menuH = ui.Dp(52) // selection-menu panel height
|
||||
)
|
||||
|
||||
// isWordRune reports whether a rune is part of a selectable word (letters,
|
||||
// digits, underscore).
|
||||
func isWordRune(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
|
||||
}
|
||||
|
||||
// windowContent returns a content accessor for the active buffer.
|
||||
func windowContent() func(a, b int) string {
|
||||
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
||||
return func(a, b int) string {
|
||||
return cb.Content(a, b)
|
||||
}
|
||||
}
|
||||
s := TheState.Editor.Buffer
|
||||
return func(a, b int) string {
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
if b > len(s) {
|
||||
b = len(s)
|
||||
}
|
||||
if a >= b {
|
||||
return ""
|
||||
}
|
||||
return s[a:b]
|
||||
}
|
||||
}
|
||||
|
||||
func fileLenBytes() int {
|
||||
if cb := TheState.Editor.ChunkedBuffer; cb != nil {
|
||||
return int(cb.FileLen())
|
||||
}
|
||||
return len(TheState.Editor.Buffer)
|
||||
}
|
||||
|
||||
// wordRangeAt returns the byte range [start, end) of the word containing the
|
||||
// rune at (or immediately before) pos. ok=false when neither is a word
|
||||
// character (e.g. a space or punctuation).
|
||||
func wordRangeAt(pos int) (start, end int, ok bool) {
|
||||
content := windowContent()
|
||||
fileLen := fileLenBytes()
|
||||
if pos < 0 {
|
||||
pos = 0
|
||||
}
|
||||
if pos > fileLen {
|
||||
pos = fileLen
|
||||
}
|
||||
// 256-byte window around pos; words longer than that are clipped (rare).
|
||||
a := pos - 256
|
||||
if a < 0 {
|
||||
a = 0
|
||||
}
|
||||
b := pos + 256
|
||||
if b > fileLen {
|
||||
b = fileLen
|
||||
}
|
||||
w := content(a, b)
|
||||
rp := pos - a
|
||||
|
||||
// Rune starting at rp, and rune ending at rp (if any).
|
||||
rAt, szAt := utf8.DecodeRuneInString(w[rp:])
|
||||
var rBefore rune
|
||||
var szBefore int
|
||||
if rp > 0 {
|
||||
j := rp - 1
|
||||
for j >= 0 && w[j]&0xC0 == 0x80 {
|
||||
j--
|
||||
}
|
||||
if j >= 0 {
|
||||
rBefore, szBefore = utf8.DecodeRuneInString(w[j:])
|
||||
}
|
||||
}
|
||||
|
||||
// expandWord grows [l, r) over consecutive word runes (byte offsets in w).
|
||||
expandWord := func(l, r int) (int, int) {
|
||||
for l > 0 {
|
||||
j := l - 1
|
||||
for j >= 0 && w[j]&0xC0 == 0x80 {
|
||||
j--
|
||||
}
|
||||
if j < 0 {
|
||||
break
|
||||
}
|
||||
r, _ := utf8.DecodeRuneInString(w[j:])
|
||||
if !isWordRune(r) {
|
||||
break
|
||||
}
|
||||
l = j
|
||||
}
|
||||
for r < len(w) {
|
||||
rr, sz := utf8.DecodeRuneInString(w[r:])
|
||||
if sz == 0 || !isWordRune(rr) {
|
||||
break
|
||||
}
|
||||
r += sz
|
||||
}
|
||||
return l, r
|
||||
}
|
||||
|
||||
if szAt > 0 && isWordRune(rAt) {
|
||||
l, r := expandWord(rp, rp+szAt)
|
||||
return a + l, a + r, true
|
||||
}
|
||||
if szBefore > 0 && isWordRune(rBefore) {
|
||||
l, r := expandWord(rp-szBefore, rp)
|
||||
return a + l, a + r, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
// pointInMenu reports whether the app-local Dp point is inside the visible
|
||||
// selection menu panel.
|
||||
func pointInMenu(x, y ui.Dp) bool {
|
||||
r := TheState.Editor.MenuRect
|
||||
return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H
|
||||
}
|
||||
|
||||
func hideSelectionMenu() {
|
||||
e := &TheState.Editor
|
||||
e.MenuVisible = false
|
||||
e.MenuItems = nil
|
||||
e.MenuRect = ui.Region{}
|
||||
}
|
||||
|
||||
// showSelectionMenu positions the copy/cut/paste menu below the line that
|
||||
// contains the selection end (or caret) and recomputes the items. Copy and
|
||||
// Cut are offered only while a selection is active; Paste always.
|
||||
func showSelectionMenu() {
|
||||
e := &TheState.Editor
|
||||
if e.TooLarge || len(e.GlyphLayout.ByteOffsets) == 0 {
|
||||
return
|
||||
}
|
||||
anchor := e.CursorPosition
|
||||
if e.SelectionEnd > anchor {
|
||||
anchor = e.SelectionEnd
|
||||
}
|
||||
glyphX, lineTop, ok := bytePosToScreenXY(anchor)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var winW, winH float64
|
||||
if TheState.scale > 0 {
|
||||
winW = float64(ui.ToDp(ui.Px(TheState.PixelWidth), TheState.scale))
|
||||
winH = float64(ui.ToDp(ui.Px(TheState.PixelHeight), TheState.scale))
|
||||
}
|
||||
var items []ui.MenuItem
|
||||
add := func(icon, label string) {
|
||||
items = append(items, ui.MenuItem{
|
||||
Icon: icon, Label: label,
|
||||
X: menuItemW * ui.Dp(len(items)), Y: 0, W: menuItemW, H: menuH,
|
||||
})
|
||||
}
|
||||
if selActive() {
|
||||
add("copy", "Copy")
|
||||
add("cut", "Cut")
|
||||
}
|
||||
add("paste", "Paste")
|
||||
menuW := menuItemW * ui.Dp(len(items))
|
||||
mx := glyphX - float64(menuW)/2
|
||||
if mx < 8 {
|
||||
mx = 8
|
||||
}
|
||||
if winW > 0 && mx+float64(menuW) > winW-8 {
|
||||
mx = winW - float64(menuW) - 8
|
||||
}
|
||||
my := lineTop + float64(EditorLineHeight()) + 8 // below the line
|
||||
if winH > 0 && my+float64(menuH) > winH-8 {
|
||||
my = lineTop - float64(menuH) - 8 // flip above the line
|
||||
}
|
||||
if my < 8 {
|
||||
my = 8
|
||||
}
|
||||
e.MenuRect = ui.Region{X: ui.Dp(mx), Y: ui.Dp(my), W: menuW, H: menuH}
|
||||
e.MenuItems = items
|
||||
e.MenuVisible = true
|
||||
}
|
||||
|
||||
// bytePosToScreenXY returns app-local Dp coordinates for absByte: the X of
|
||||
// the glyph at (or just before) the byte, and the top Y of the visual line
|
||||
// containing it. ok=false when the layout is empty or the byte is outside
|
||||
// the visible window. This is the inverse of textPosFromLocalPoint.
|
||||
func bytePosToScreenXY(absByte int) (glyphX, lineTop float64, ok bool) {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
reg := TheState.EditorRegion
|
||||
if len(layout.ByteOffsets) == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
base := glyphBase()
|
||||
pos := absByte - base
|
||||
windowLen := len(TheState.Editor.IMEWindowText)
|
||||
if pos < 0 || pos > windowLen {
|
||||
return 0, 0, false
|
||||
}
|
||||
lineHeight := float64(EditorLineHeight())
|
||||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||||
return layout.ByteOffsets[i] >= pos
|
||||
})
|
||||
if idx < len(layout.ByteOffsets) && layout.ByteOffsets[idx] > pos {
|
||||
idx--
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(layout.ByteOffsets) {
|
||||
// pos is at/past EOF: anchor on the last glyph.
|
||||
idx = len(layout.ByteOffsets) - 1
|
||||
}
|
||||
gx := float64(reg.X) + float64(layout.X[idx])
|
||||
// layout.Y is the baseline; the baseline offset within a line is always
|
||||
// < lineHeight, so int(baseline/lineHeight) is the visual line index.
|
||||
visualLine := int(float64(layout.Y[idx]) / lineHeight)
|
||||
if visualLine < 0 {
|
||||
visualLine = 0
|
||||
}
|
||||
lt := float64(reg.Y) + float64(visualLine)*lineHeight
|
||||
return gx, lt, true
|
||||
}
|
||||
|
||||
// tapInputGuard swallows editor input for a short window after a file opens:
|
||||
// the tap that opened the file must not also reposition the caret.
|
||||
func tapInputGuard() bool {
|
||||
return time.Since(TheState.justOpenedAt) < 300*time.Millisecond
|
||||
}
|
||||
|
||||
// HandleTapAt places the caret at app-local Dp point (x, y). A tap on the
|
||||
// visible selection menu is ignored (the menu's own Tap interaction handles
|
||||
// it); any other tap dismisses the menu and ends a caret drag.
|
||||
func HandleTapAt(x, y ui.Dp) {
|
||||
if tapInputGuard() {
|
||||
return
|
||||
}
|
||||
e := &TheState.Editor
|
||||
if e.MenuVisible && pointInMenu(x, y) {
|
||||
return
|
||||
}
|
||||
e.CaretDrag = false
|
||||
hideSelectionMenu()
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
SetCursorFromPoint(localX, localY)
|
||||
}
|
||||
|
||||
// HandleLongPressAt implements Android long-press: on a word it selects the
|
||||
// word; on blank space it sets the caret there and shows a single draggable
|
||||
// caret handle. The selection menu is shown either way.
|
||||
func HandleLongPressAt(x, y ui.Dp) {
|
||||
if tapInputGuard() {
|
||||
return
|
||||
}
|
||||
e := &TheState.Editor
|
||||
if e.TooLarge {
|
||||
return
|
||||
}
|
||||
if e.MenuVisible && pointInMenu(x, y) {
|
||||
return
|
||||
}
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ws, we, found := wordRangeAt(pos); found {
|
||||
SetSelection(ws, we)
|
||||
e.CaretDrag = false
|
||||
} else {
|
||||
ClearSelection()
|
||||
e.CursorPosition = pos
|
||||
e.CaretDrag = true
|
||||
}
|
||||
showSelectionMenu()
|
||||
}
|
||||
|
||||
// HandleDoubleTapAt selects the word under the finger (and shows the menu);
|
||||
// on blank space it just places the caret.
|
||||
func HandleDoubleTapAt(x, y ui.Dp) {
|
||||
if tapInputGuard() {
|
||||
return
|
||||
}
|
||||
e := &TheState.Editor
|
||||
if e.TooLarge {
|
||||
return
|
||||
}
|
||||
if e.MenuVisible && pointInMenu(x, y) {
|
||||
return
|
||||
}
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ws, we, found := wordRangeAt(pos); found {
|
||||
SetSelection(ws, we)
|
||||
e.CaretDrag = false
|
||||
showSelectionMenu()
|
||||
} else {
|
||||
SetCursorFromPoint(localX, localY)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleSelDragEvt is the registered SelDrag interaction handler. The
|
||||
// renderer delivers SelectionDragEvent (finger position) and SelectionDragEnd.
|
||||
func HandleSelDragEvt(data any) {
|
||||
switch ev := data.(type) {
|
||||
case ui.SelectionDragEvent:
|
||||
selDragMove(ev.Which, ev.X, ev.Y)
|
||||
case ui.SelectionDragEnd:
|
||||
TheState.Editor.SelDragging = false
|
||||
TheState.Editor.SelDragRel = 0
|
||||
// The caret handle (long press on blank space) is a transient affordance;
|
||||
// the selection handles come back from the selection state on the next
|
||||
// frame, so only the caret mode is dropped here.
|
||||
TheState.Editor.CaretDrag = false
|
||||
}
|
||||
}
|
||||
|
||||
// selDragMove applies one finger position of a selection/caret drag.
|
||||
func selDragMove(which int, x, y ui.Dp) {
|
||||
e := &TheState.Editor
|
||||
localX := float64(x - TheState.EditorRegion.X)
|
||||
localY := tapLocalY(y, TheState.EditorRegion.Y, TheState.ScrollOffset)
|
||||
pos, ok := textPosFromLocalPoint(localX, localY)
|
||||
if !e.SelDragging {
|
||||
e.SelDragging = true
|
||||
e.SelDragWhich = which
|
||||
if which == 2 && ok {
|
||||
// Body drag: the grab fixes the finger's offset from the selection
|
||||
// start; the selection itself moves on later events.
|
||||
e.SelDragRel = pos - e.SelectionStart
|
||||
if e.SelDragRel < 0 {
|
||||
e.SelDragRel = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
// Start/end/caret handles: the first event already positions the
|
||||
// handle (no grab offset needed).
|
||||
}
|
||||
if !ok {
|
||||
return // finger outside the laid-out window: keep last position
|
||||
}
|
||||
switch e.SelDragWhich {
|
||||
case 0: // start handle
|
||||
if pos > e.SelectionEnd {
|
||||
pos = e.SelectionEnd
|
||||
}
|
||||
SetSelection(pos, e.SelectionEnd)
|
||||
case 1: // end handle
|
||||
if pos < e.SelectionStart {
|
||||
pos = e.SelectionStart
|
||||
}
|
||||
SetSelection(e.SelectionStart, pos)
|
||||
case 2: // body: move the whole selection, preserving length
|
||||
selLen := e.SelectionEnd - e.SelectionStart
|
||||
ns := pos - e.SelDragRel
|
||||
fl := fileLenBytes()
|
||||
if ns < 0 {
|
||||
ns = 0
|
||||
}
|
||||
if ns+selLen > fl {
|
||||
ns = fl - selLen
|
||||
}
|
||||
SetSelection(ns, ns+selLen)
|
||||
case 3: // caret drag handle
|
||||
e.CursorPosition = pos
|
||||
}
|
||||
}
|
||||
|
||||
// HandleMenuTap hit-tests a tap on the menu panel and runs the tapped item.
|
||||
func HandleMenuTap(x, y ui.Dp) {
|
||||
e := &TheState.Editor
|
||||
if !e.MenuVisible || !pointInMenu(x, y) {
|
||||
return
|
||||
}
|
||||
idx := int((x - e.MenuRect.X) / menuItemW)
|
||||
if idx < 0 || idx >= len(e.MenuItems) {
|
||||
return
|
||||
}
|
||||
switch e.MenuItems[idx].Icon {
|
||||
case "copy":
|
||||
handleMenuCopy()
|
||||
case "cut":
|
||||
handleMenuCut()
|
||||
case "paste":
|
||||
handleMenuPaste()
|
||||
}
|
||||
}
|
||||
|
||||
// selectedText returns the selected bytes.
|
||||
func selectedText() (string, bool) {
|
||||
e := &TheState.Editor
|
||||
if !selActive() {
|
||||
return "", false
|
||||
}
|
||||
if cb := e.ChunkedBuffer; cb != nil {
|
||||
return cb.Content(e.SelectionStart, e.SelectionEnd), true
|
||||
}
|
||||
return e.Buffer[e.SelectionStart:e.SelectionEnd], true
|
||||
}
|
||||
|
||||
func handleMenuCopy() {
|
||||
text, ok := selectedText()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
TheState.clipboardSetChan <- text
|
||||
// The selection (and its highlight) stays; only the menu goes away,
|
||||
// matching Android behaviour.
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
func handleMenuCut() {
|
||||
e := &TheState.Editor
|
||||
text, ok := selectedText()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
TheState.clipboardSetChan <- text
|
||||
deleteRange(e.SelectionStart, e.SelectionEnd)
|
||||
markDirty()
|
||||
e.CursorPosition = e.SelectionStart
|
||||
ClearSelection()
|
||||
}
|
||||
|
||||
func handleMenuPaste() {
|
||||
if TheState.Editor.TooLarge {
|
||||
return
|
||||
}
|
||||
TheState.pasteReqChan <- struct{}{}
|
||||
// The read is asynchronous (DataEvent on a later frame), but the menu
|
||||
// has served its purpose and closes immediately, as on Android.
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
// HandlePaste inserts the system clipboard text (replacing a live selection,
|
||||
// per the selection-aware edit rule). Runs on the logic goroutine.
|
||||
func HandlePaste(text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
HandleInsert(text)
|
||||
}
|
||||
|
||||
// HandleHome moves the cursor to the start of the current visual line.
|
||||
func HandleHome() {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
|
|
@ -1066,6 +1564,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
W: screenWidth - margin*2,
|
||||
H: editorH,
|
||||
}
|
||||
// Stored for the input handlers (tap / long press / drags), which convert
|
||||
// app-local Dp points to text-local coordinates through it.
|
||||
TheState.EditorRegion = editorRegion
|
||||
// Compute max scroll offset from the last line baseline reported by the renderer.
|
||||
// lastLineY is the shaper's Y value for the last line's baseline.
|
||||
// Add bottom padding (half line height) so last line isn't flush with the bottom bar.
|
||||
|
|
@ -1216,30 +1717,41 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
|
|||
[]ui.Interaction{
|
||||
{Gesture: ui.Scroll, Handler: HandleScroll},
|
||||
{Gesture: ui.KeyDown, Handler: HandleKeyDown},
|
||||
// The Tap interaction also carries the long-press and double-tap
|
||||
// events the renderer derives from the same gesture.Click (they share
|
||||
// the editor's click region). The renderer converts all of them to
|
||||
// text-local coordinates through EditorRegion + tapLocalY.
|
||||
{Gesture: ui.Tap, Handler: func(data any) {
|
||||
// Swallow the tap that opened the file. Its gesture belongs to the
|
||||
// browser row we just left and must not position the cursor in the
|
||||
// editor (a deliberate tap-to-position happens later, well past this
|
||||
// window).
|
||||
if time.Since(TheState.justOpenedAt) < 300*time.Millisecond {
|
||||
return
|
||||
}
|
||||
if pt, ok := data.(ui.Point); ok {
|
||||
// Window-space tap -> window-relative text-local coordinates.
|
||||
// (See tapLocalY: the GlyphLayout is window-relative, so the Y must
|
||||
// be too — never add the full scroll offset here.)
|
||||
localX := float64(pt.X - editorRegion.X)
|
||||
localY := tapLocalY(pt.Y, editorRegion.Y, TheState.ScrollOffset)
|
||||
SetCursorFromPoint(localX, localY)
|
||||
// The just-opened guard (swallowing the tap that opened the file)
|
||||
// lives inside the Handle* functions.
|
||||
switch pt := data.(type) {
|
||||
case ui.Point:
|
||||
HandleTapAt(pt.X, pt.Y)
|
||||
case ui.DoubleTapPoint:
|
||||
HandleDoubleTapAt(pt.X, pt.Y)
|
||||
case ui.LongPressPoint:
|
||||
HandleLongPressAt(pt.X, pt.Y)
|
||||
}
|
||||
}},
|
||||
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
|
||||
},
|
||||
)
|
||||
// Set Focused so TextField.Draw() issues key.FocusCmd, which is required
|
||||
// for Gio to deliver key events to this element.
|
||||
editorElem.Focused = TheState.FocusedElementID == "editor_text"
|
||||
// Caret handle visibility (long press on blank space).
|
||||
editorElem.CaretDrag = TheState.Editor.CaretDrag
|
||||
|
||||
return []ui.Element{statusBar, editorElem, bottomBar}
|
||||
elems := []ui.Element{statusBar, editorElem, bottomBar}
|
||||
// The selection menu is added last so it draws on top of the editor.
|
||||
if TheState.Editor.MenuVisible {
|
||||
elems = append(elems, ui.NewMenu("selection_menu", TheState.Editor.MenuRect, TheState.Editor.MenuItems, func(data any) {
|
||||
if pt, ok := data.(ui.Point); ok {
|
||||
HandleMenuTap(pt.X, pt.Y)
|
||||
}
|
||||
}))
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
// tapLocalY converts a tap's screen Y (Dp) to a text-local Y in the
|
||||
|
|
@ -1254,13 +1766,25 @@ func tapLocalY(ptY, regionTopY ui.Dp, scrollOffset ui.Dp) float64 {
|
|||
return float64(ptY-regionTopY) + math.Mod(float64(scrollOffset), float64(EditorLineHeight()))
|
||||
}
|
||||
|
||||
// SetCursorFromPoint updates the cursor position based on screen coordinates (Dp).
|
||||
// SetCursorFromPoint updates the cursor position based on text-local
|
||||
// coordinates (Dp, window-relative: x from the text region's left edge, y as
|
||||
// produced by tapLocalY). A tap is an explicit cursor placement: it always
|
||||
// clears any selection.
|
||||
func SetCursorFromPoint(x, y float64) {
|
||||
// A tap is an explicit cursor placement: it always clears any selection.
|
||||
ClearSelection()
|
||||
if pos, ok := textPosFromLocalPoint(x, y); ok {
|
||||
TheState.Editor.CursorPosition = pos
|
||||
}
|
||||
}
|
||||
|
||||
// textPosFromLocalPoint maps text-local coordinates (Dp, window-relative) to
|
||||
// an absolute byte offset in the buffer. ok=false when the layout is empty or
|
||||
// the point maps to no glyph. Shared by the tap, long-press, double-tap and
|
||||
// selection-drag handlers.
|
||||
func textPosFromLocalPoint(x, y float64) (int, bool) {
|
||||
layout := TheState.Editor.GlyphLayout
|
||||
if len(layout.ByteOffsets) == 0 || len(layout.X) == 0 || len(layout.Advance) == 0 {
|
||||
return
|
||||
return 0, false
|
||||
}
|
||||
|
||||
base := glyphBase()
|
||||
|
|
@ -1329,7 +1853,7 @@ func SetCursorFromPoint(x, y float64) {
|
|||
}
|
||||
}
|
||||
if len(groups[visualLine].indices) == 0 {
|
||||
return
|
||||
return 0, false
|
||||
}
|
||||
|
||||
targetGroup := groups[visualLine]
|
||||
|
|
@ -1355,7 +1879,7 @@ func SetCursorFromPoint(x, y float64) {
|
|||
content, err := buf.FullContent()
|
||||
if err != nil {
|
||||
log.Printf("Error getting full content: %v", err)
|
||||
return
|
||||
return 0, false
|
||||
}
|
||||
fileContent = content
|
||||
} else {
|
||||
|
|
@ -1363,11 +1887,10 @@ func SetCursorFromPoint(x, y float64) {
|
|||
}
|
||||
r, size := utf8.DecodeRuneInString(fileContent[start:])
|
||||
if r == '\n' {
|
||||
TheState.Editor.CursorPosition = start
|
||||
return start, true
|
||||
} else {
|
||||
TheState.Editor.CursorPosition = start + size
|
||||
return start + size, true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Otherwise, find the closest glyph on this line.
|
||||
|
|
@ -1389,6 +1912,7 @@ func SetCursorFromPoint(x, y float64) {
|
|||
}
|
||||
}
|
||||
if bestIdx != -1 {
|
||||
TheState.Editor.CursorPosition = base + layout.ByteOffsets[bestIdx]
|
||||
return base + layout.ByteOffsets[bestIdx], true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
|
|
|||
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,10 +192,13 @@ type TextField struct {
|
|||
// in-app highlight.
|
||||
SelectionStart int
|
||||
SelectionEnd int
|
||||
ScrollOffset Dp
|
||||
VisibleLines []Line
|
||||
WordWrap bool
|
||||
WrapWidth Dp
|
||||
// CaretDrag is set after a long press on blank space: a single caret
|
||||
// handle is shown and can be dragged to move the caret.
|
||||
CaretDrag bool
|
||||
ScrollOffset Dp
|
||||
VisibleLines []Line
|
||||
WordWrap bool
|
||||
WrapWidth Dp
|
||||
}
|
||||
|
||||
func (tf TextField) Type() string { return "textfield" }
|
||||
|
|
@ -290,7 +293,7 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
|||
r.lastSelStart = -1
|
||||
r.lastSelCaret = -1
|
||||
}
|
||||
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd)
|
||||
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag)
|
||||
}
|
||||
|
||||
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
|
||||
|
|
@ -849,6 +852,10 @@ const (
|
|||
Scroll
|
||||
KeyDown
|
||||
KeyUp
|
||||
// SelDrag is a selection-handle / selection-body / caret-handle drag.
|
||||
// The renderer registers the underlying gesture.Drag ops itself (it owns
|
||||
// the handle geometry); this interaction just delivers the logic handler.
|
||||
SelDrag
|
||||
)
|
||||
|
||||
// Interaction pairs a gesture type with a handler function.
|
||||
|
|
@ -877,6 +884,86 @@ type Point struct {
|
|||
X, Y Dp
|
||||
}
|
||||
|
||||
// LongPressPoint is a touch that was held still for the long-press duration.
|
||||
// Coordinates are app-local window Dp (the same space as Point from a tap).
|
||||
type LongPressPoint struct {
|
||||
X, Y Dp
|
||||
}
|
||||
|
||||
// DoubleTapPoint is the second tap of a double tap. App-local window Dp.
|
||||
type DoubleTapPoint struct {
|
||||
X, Y Dp
|
||||
}
|
||||
|
||||
// SelectionDragEvent is emitted while a selection handle (Which 0 = start,
|
||||
// 1 = end), the selection body (Which 2 = move whole selection) or the
|
||||
// caret drag handle (Which 3) is being dragged. X/Y are app-local window Dp.
|
||||
type SelectionDragEvent struct {
|
||||
Which int
|
||||
X, Y Dp
|
||||
}
|
||||
|
||||
// SelectionDragEnd is emitted when a selection/caret drag is released.
|
||||
type SelectionDragEnd struct{}
|
||||
|
||||
// MenuItem is one button in the selection menu.
|
||||
// X/Y/W/H are relative to the Menu region.
|
||||
type MenuItem struct {
|
||||
Icon string // "copy", "cut", "paste"
|
||||
Label string
|
||||
X, Y, W, H Dp
|
||||
}
|
||||
|
||||
// Menu is the floating selection menu (Copy / Cut / Paste). The logic goroutine
|
||||
// positions it and decides the items; the renderer draws it and routes a tap
|
||||
// to the element's Tap interaction with the tapped Point, which the logic
|
||||
// hit-tests against its own items (single source of truth for geometry).
|
||||
type Menu struct {
|
||||
id string
|
||||
region Region
|
||||
visible bool
|
||||
items []MenuItem
|
||||
tap []Interaction
|
||||
}
|
||||
|
||||
func NewMenu(id string, region Region, items []MenuItem, tapHandler func(any)) Menu {
|
||||
return Menu{
|
||||
id: id,
|
||||
region: region,
|
||||
visible: true,
|
||||
items: items,
|
||||
tap: []Interaction{{Gesture: Tap, Handler: tapHandler}},
|
||||
}
|
||||
}
|
||||
|
||||
func (m Menu) Type() string { return "menu" }
|
||||
func (m Menu) Region() Region { return m.region }
|
||||
func (m Menu) Visible() bool { return m.visible }
|
||||
func (m Menu) ID() string { return m.id }
|
||||
func (m Menu) Interactions() []Interaction { return m.tap }
|
||||
func (m Menu) Items() []MenuItem { return m.items }
|
||||
|
||||
func (m Menu) String() string {
|
||||
return fmt.Sprintf("Menu[%s] region=%+v items=%d", m.id, m.region, len(m.items))
|
||||
}
|
||||
|
||||
// Draw renders the menu panel and its items. Click registration happens in
|
||||
// the renderer's leaf-element path (Tap over the whole panel).
|
||||
func (m Menu) Draw(gtx layout.Context, r *Renderer) {
|
||||
// Panel background (light, like an Android floating menu).
|
||||
r.drawBg(gtx, m.region, Color{R: 245, G: 245, B: 245, A: 255})
|
||||
for _, it := range m.items {
|
||||
// Item X/Y are relative to the menu region; offset to app coordinates.
|
||||
ofx, ofy := m.region.X, m.region.Y
|
||||
img := r.icon(it.Icon)
|
||||
iconSize := it.H / 2
|
||||
iconX := ofx + it.X + (it.W-iconSize)/2
|
||||
iconY := ofy + it.Y + 2
|
||||
r.drawPng(gtx, img, Region{X: iconX, Y: iconY, W: iconSize, H: iconSize}, iconSize, iconSize)
|
||||
r.drawText(gtx, it.Label, unit.Sp(10), Region{X: ofx + it.X, Y: ofy + it.Y + iconSize + 3, W: it.W, H: 12}, AlignCenter, Color{R: 30, G: 30, B: 30, A: 255}, "")
|
||||
}
|
||||
}
|
||||
|
||||
// InputEvent represents a user input event with its handler.
|
||||
type InputEvent struct {
|
||||
Handler func(any)
|
||||
|
|
|
|||
|
|
@ -36,8 +36,21 @@ var iconFS embed.FS
|
|||
type clickReg struct {
|
||||
click *gesture.Click
|
||||
handler func(any)
|
||||
// pressAt/pressPos record the current press (set on KindPress) so the
|
||||
// per-frame long-press check knows how long the finger has been still.
|
||||
pressAt time.Time
|
||||
pressPos image.Point
|
||||
longFired bool
|
||||
}
|
||||
|
||||
// longPressDuration is how long a still press must hold before a long-press
|
||||
// fires (Android uses ~500ms; 400ms feels snappier for text selection).
|
||||
const longPressDuration = 400 * time.Millisecond
|
||||
|
||||
// longPressSlopPx is how far the finger may drift (px) before a pending
|
||||
// long press is cancelled (that motion becomes a scroll/drag instead).
|
||||
const longPressSlopPx = 8
|
||||
|
||||
// keyReg pairs a handler for key events.
|
||||
type keyReg struct {
|
||||
Handler func(any)
|
||||
|
|
@ -59,7 +72,7 @@ type Renderer struct {
|
|||
shp *text.Shaper
|
||||
scale float32 // px-per-Dp for the current draw pass; set in Draw
|
||||
icons map[string]image.Image
|
||||
clicks map[string]clickReg
|
||||
clicks map[string]*clickReg
|
||||
Keys map[string]keyReg // Exported Keys map
|
||||
scrolls map[string]scrollReg
|
||||
gioEditors map[string]*widget.Editor // main-owned widget editors by element ID
|
||||
|
|
@ -77,6 +90,35 @@ type Renderer struct {
|
|||
lastSnippet key.Snippet
|
||||
lastSelStart int // window-relative rune index of last-pushed selection start; -1 = no selection
|
||||
lastSelCaret int // window-relative rune index of last-pushed selection end/caret
|
||||
|
||||
// Long-press detection. pressProbe is a plain event tag observing raw
|
||||
// pointer events inside the editor text region: gesture.Click reports
|
||||
// nothing until release, so a long press (finger held still for
|
||||
// longPressDuration) can only be detected this way. ppMoved cancels the
|
||||
// pending long press once the finger leaves longPressSlopPx (it is a
|
||||
// scroll/drag then, not a press). longPressID gates the long-press to the
|
||||
// editor's click reg (browser rows etc. don't long-press). ppLast is in
|
||||
// f32.Point because pointer.Event.Position is window-space f32.
|
||||
pressProbe struct{}
|
||||
ppLast f32.Point
|
||||
ppActive bool
|
||||
ppMoved bool
|
||||
longPressID string
|
||||
|
||||
// Selection / caret drag handles (0 = start, 1 = end, 2 = body, 3 = caret
|
||||
// handle). Registered clipped in drawWrappedText only while a selection or
|
||||
// caret handle is visible; a gesture.Drag grabs the pointer once movement
|
||||
// exceeds slop, which cancels the scroll and click handlers so a handle
|
||||
// drag never fights a fling. selDragEmitting tracks whether a Drag event
|
||||
// was delivered for the current gesture (so a plain tap on a handle does
|
||||
// not emit a spurious SelectionDragEnd).
|
||||
selDragStart gesture.Drag
|
||||
selDragEnd gesture.Drag
|
||||
selDragBody gesture.Drag
|
||||
selDragCaret gesture.Drag
|
||||
selDragsOn [4]bool
|
||||
selDragEmitting [4]bool
|
||||
selDragHandler func(any)
|
||||
}
|
||||
|
||||
// New creates a new Renderer.
|
||||
|
|
@ -85,7 +127,7 @@ func New(th Theme, shp *text.Shaper) *Renderer {
|
|||
theme: th,
|
||||
shp: shp,
|
||||
icons: make(map[string]image.Image),
|
||||
clicks: make(map[string]clickReg),
|
||||
clicks: make(map[string]*clickReg),
|
||||
Keys: make(map[string]keyReg),
|
||||
scrolls: make(map[string]scrollReg),
|
||||
gioEditors: make(map[string]*widget.Editor),
|
||||
|
|
@ -169,10 +211,10 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
|||
case Tap:
|
||||
reg, ok := r.clicks[id]
|
||||
if !ok {
|
||||
reg = clickReg{click: &gesture.Click{}}
|
||||
reg = &clickReg{click: &gesture.Click{}}
|
||||
r.clicks[id] = reg
|
||||
}
|
||||
reg.handler = interaction.Handler
|
||||
r.clicks[id] = reg
|
||||
// Register click within current clip context
|
||||
reg.click.Add(gtx.Ops)
|
||||
case Scroll:
|
||||
|
|
@ -184,6 +226,11 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
|||
reg.scroll.Add(gtx.Ops)
|
||||
reg.handler = interaction.Handler
|
||||
r.scrolls[id] = reg
|
||||
case SelDrag:
|
||||
// The renderer registers the actual gesture.Drag ops in
|
||||
// drawWrappedText (it owns the handle geometry); this only records the
|
||||
// logic handler that receives the drag events.
|
||||
r.selDragHandler = interaction.Handler
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -193,10 +240,10 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l
|
|||
func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) {
|
||||
reg, ok := r.clicks[id]
|
||||
if !ok {
|
||||
reg = clickReg{click: &gesture.Click{}}
|
||||
reg = &clickReg{click: &gesture.Click{}}
|
||||
r.clicks[id] = reg
|
||||
}
|
||||
reg.handler = handler
|
||||
r.clicks[id] = reg
|
||||
// Clip to the specified region for click area
|
||||
clickClip := clip.Rect{
|
||||
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))},
|
||||
|
|
@ -219,17 +266,101 @@ func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region,
|
|||
reg.scroll.Add(gtx.Ops)
|
||||
}
|
||||
|
||||
// PendingLongPress reports whether a press is currently held still on the
|
||||
// editor (long-press armed but not yet fired). Gio renders on demand: with a
|
||||
// stationary finger there are no pointer events, hence no frames, and the
|
||||
// 400 ms threshold could never be checked. The main loop calls this and
|
||||
// invalidates the window while it is true, keeping frames flowing until the
|
||||
// long press fires or the finger moves up.
|
||||
func (r *Renderer) PendingLongPress() bool {
|
||||
reg, ok := r.clicks[r.longPressID]
|
||||
return ok && reg.click.Pressed() && !reg.longFired && !r.ppMoved && !reg.pressAt.IsZero()
|
||||
}
|
||||
|
||||
// CheckGestures checks all registered gestures and returns any events.
|
||||
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
||||
var events []InputEvent
|
||||
for _, reg := range r.clicks {
|
||||
evt, ok := reg.click.Update(q)
|
||||
if ok {
|
||||
if evt.Kind == gesture.KindClick {
|
||||
// Long-press motion probe first: it must see the press/drag events before
|
||||
// the click loop decides about a long press.
|
||||
r.consumePressProbe(q)
|
||||
for id, reg := range r.clicks {
|
||||
// Drain every queued event for this gesture in this frame.
|
||||
// gesture.Click returns one event per Update call, but on Android a
|
||||
// tap's press and release routinely arrive in the same frame. Without
|
||||
// draining, the release would sit unprocessed until the next redraw —
|
||||
// which on an idle window may never come — and the tap is swallowed.
|
||||
for {
|
||||
evt, ok := reg.click.Update(q)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch evt.Kind {
|
||||
case gesture.KindPress:
|
||||
reg.pressAt = time.Now()
|
||||
reg.pressPos = evt.Position
|
||||
reg.longFired = false
|
||||
case gesture.KindClick:
|
||||
if reg.longFired {
|
||||
break // the press was consumed as a long press
|
||||
}
|
||||
if evt.NumClicks >= 2 {
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: DoubleTapPoint{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||||
})
|
||||
} else {
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||||
})
|
||||
}
|
||||
case gesture.KindCancel:
|
||||
reg.pressAt = time.Time{}
|
||||
reg.longFired = false
|
||||
}
|
||||
}
|
||||
// Long press: the finger must still be down on the probed (editor)
|
||||
// region, held still, for the long-press duration.
|
||||
if id == r.longPressID && reg.click.Pressed() && !reg.longFired &&
|
||||
!r.ppMoved && !reg.pressAt.IsZero() && time.Since(reg.pressAt) >= longPressDuration {
|
||||
reg.longFired = true
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: LongPressPoint{X: r.toDp(Px(reg.pressPos.X)), Y: r.toDp(Px(reg.pressPos.Y))},
|
||||
})
|
||||
}
|
||||
}
|
||||
// Selection / caret drags before scroll: a handle grab must win over fling.
|
||||
drags := [4]*gesture.Drag{&r.selDragStart, &r.selDragEnd, &r.selDragBody, &r.selDragCaret}
|
||||
for which, d := range drags {
|
||||
if !r.selDragsOn[which] || r.selDragHandler == nil {
|
||||
continue
|
||||
}
|
||||
// wasDragging is captured before Update: Update resets Dragging() on
|
||||
// Release/Cancel, so it would read false afterwards.
|
||||
wasDragging := d.Dragging()
|
||||
// Drain all queued events for this drag in this frame (same
|
||||
// one-event-per-Update rationale as the click loop above).
|
||||
for {
|
||||
e, ok := d.Update(m, q, gesture.Both)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
switch e.Kind {
|
||||
case pointer.Drag:
|
||||
r.selDragEmitting[which] = true
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: Point{X: r.toDp(Px(evt.Position.X)), Y: r.toDp(Px(evt.Position.Y))},
|
||||
Handler: r.selDragHandler,
|
||||
Data: SelectionDragEvent{Which: which, X: r.toDp(Px(e.Position.X)), Y: r.toDp(Px(e.Position.Y))},
|
||||
})
|
||||
case pointer.Release, pointer.Cancel:
|
||||
if wasDragging && r.selDragEmitting[which] {
|
||||
events = append(events, InputEvent{
|
||||
Handler: r.selDragHandler,
|
||||
Data: SelectionDragEnd{},
|
||||
})
|
||||
}
|
||||
r.selDragEmitting[which] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -249,6 +380,38 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
|||
return events
|
||||
}
|
||||
|
||||
// consumePressProbe drains the raw pointer events of the long-press probe
|
||||
// and updates ppLast/ppMoved. It runs before the click loop each frame.
|
||||
func (r *Renderer) consumePressProbe(q input.Source) {
|
||||
for {
|
||||
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pe, ok := evt.(pointer.Event)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch pe.Kind {
|
||||
case pointer.Press:
|
||||
r.ppLast = pe.Position
|
||||
r.ppActive = true
|
||||
r.ppMoved = false
|
||||
case pointer.Drag:
|
||||
if r.ppActive {
|
||||
dx, dy := pe.Position.X-r.ppLast.X, pe.Position.Y-r.ppLast.Y
|
||||
if dx*dx+dy*dy > float32(longPressSlopPx*longPressSlopPx) {
|
||||
r.ppMoved = true
|
||||
}
|
||||
}
|
||||
r.ppLast = pe.Position
|
||||
case pointer.Release, pointer.Cancel:
|
||||
r.ppMoved = false
|
||||
r.ppActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DisplayLineCount returns the number of display lines from the last
|
||||
// drawWrappedText call. Used by the main loop to report back to logic.
|
||||
func (r *Renderer) DisplayLineCount() int {
|
||||
|
|
@ -321,12 +484,17 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
|||
if interaction.Gesture == Tap {
|
||||
reg, ok := r.clicks[interactive.ID()]
|
||||
if !ok {
|
||||
reg = clickReg{click: &gesture.Click{}}
|
||||
reg = &clickReg{click: &gesture.Click{}}
|
||||
r.clicks[interactive.ID()] = reg
|
||||
}
|
||||
reg.handler = interaction.Handler
|
||||
r.clicks[interactive.ID()] = reg
|
||||
reg.click.Add(gtx.Ops)
|
||||
}
|
||||
if interaction.Gesture == SelDrag {
|
||||
// Store the logic handler; the drag ops themselves are added
|
||||
// clipped in drawWrappedText where the handle geometry is known.
|
||||
r.registerInteraction(interactive.ID(), interaction, gtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
e.Draw(gtx, r)
|
||||
|
|
@ -352,10 +520,10 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
|||
if interaction.Gesture == Tap {
|
||||
reg, ok := r.clicks[interactive.ID()]
|
||||
if !ok {
|
||||
reg = clickReg{click: &gesture.Click{}}
|
||||
reg = &clickReg{click: &gesture.Click{}}
|
||||
r.clicks[interactive.ID()] = reg
|
||||
}
|
||||
reg.handler = interaction.Handler
|
||||
r.clicks[interactive.ID()] = reg
|
||||
}
|
||||
// For non-text elements, register click immediately
|
||||
if _, isLabel := e.(Label); !isLabel {
|
||||
|
|
@ -482,7 +650,7 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
|
|||
// detection via WrapHeuristically. Long words overflow the wrap width.
|
||||
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
||||
// of glyph metrics. The shaper's first.Y accounts for line spacing.
|
||||
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int) {
|
||||
func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, wrapWidth Dp, scrollOffset Dp, cursorPos, selStart, selEnd int, caretDrag bool) {
|
||||
if str == "" {
|
||||
return
|
||||
}
|
||||
|
|
@ -620,6 +788,96 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
}
|
||||
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
|
||||
|
||||
// Long-press probe and selection/caret drag handles. The probe op and the
|
||||
// (clipped) drag registrations live in the text clip so they only respond
|
||||
// inside the editor region.
|
||||
r.selDragsOn = [4]bool{}
|
||||
event.Op(gtx.Ops, r.pressProbe)
|
||||
r.longPressID = "editor_text"
|
||||
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
|
||||
lineH := Dp(r.theme.FontSize) * 1.2
|
||||
// handleAt mirrors the cursor computation above: window-relative byte
|
||||
// offset -> screen Dp of the caret insertion point.
|
||||
handleAt := func(byteOff int) (x, y Dp) {
|
||||
idx := sort.Search(len(layout.ByteOffsets), func(i int) bool {
|
||||
return layout.ByteOffsets[i] >= byteOff
|
||||
})
|
||||
if idx < len(layout.ByteOffsets) {
|
||||
return reg.X + layout.X[idx], reg.Y - scrollOffset + layout.Y[idx] - Dp(r.theme.FontSize)
|
||||
}
|
||||
n := len(layout.X) - 1
|
||||
return reg.X + layout.X[n] + layout.Advance[n], reg.Y - scrollOffset + layout.Y[n] - Dp(r.theme.FontSize)
|
||||
}
|
||||
registerDrag := func(d *gesture.Drag, hx, hy Dp) {
|
||||
hc := clip.Rect{
|
||||
Min: image.Point{X: int(r.toPx(hx - 8)), Y: int(r.toPx(hy - 6))},
|
||||
Max: image.Point{X: int(r.toPx(hx + 8)), Y: int(r.toPx(hy + lineH + 6))},
|
||||
}.Push(gtx.Ops)
|
||||
d.Add(gtx.Ops)
|
||||
hc.Pop()
|
||||
}
|
||||
if selStart >= 0 && selEnd > selStart {
|
||||
sx, sy := handleAt(selStart)
|
||||
ex, ey := handleAt(selEnd)
|
||||
registerDrag(&r.selDragStart, sx, sy)
|
||||
r.selDragsOn[0] = true
|
||||
registerDrag(&r.selDragEnd, ex, ey)
|
||||
r.selDragsOn[1] = true
|
||||
// Body: bounding box of the selected glyphs (only for 2+ glyphs; a
|
||||
// single-glyph selection is already covered by its two handles).
|
||||
var bx0, by0, bx1, by1 Dp
|
||||
hasGlyph := false
|
||||
for i := range layout.ByteOffsets {
|
||||
b0 := layout.ByteOffsets[i]
|
||||
b1 := len(str)
|
||||
if i+1 < len(layout.ByteOffsets) {
|
||||
b1 = layout.ByteOffsets[i+1]
|
||||
}
|
||||
if b0 >= selEnd || b1 <= selStart {
|
||||
continue
|
||||
}
|
||||
gx := reg.X + layout.X[i]
|
||||
gy := reg.Y - scrollOffset + layout.Y[i] - Dp(r.theme.FontSize)
|
||||
gw := layout.Advance[i]
|
||||
gh := lineH
|
||||
if !hasGlyph {
|
||||
bx0, by0, bx1, by1 = gx, gy, gx+gw, gy+gh
|
||||
hasGlyph = true
|
||||
} else {
|
||||
if gx < bx0 {
|
||||
bx0 = gx
|
||||
}
|
||||
if gy < by0 {
|
||||
by0 = gy
|
||||
}
|
||||
if gx+gw > bx1 {
|
||||
bx1 = gx + gw
|
||||
}
|
||||
if gy+gh > by1 {
|
||||
by1 = gy + gh
|
||||
}
|
||||
}
|
||||
}
|
||||
if hasGlyph {
|
||||
bc := clip.Rect{
|
||||
Min: image.Point{X: int(r.toPx(bx0)), Y: int(r.toPx(by0))},
|
||||
Max: image.Point{X: int(r.toPx(bx1)), Y: int(r.toPx(by1))},
|
||||
}.Push(gtx.Ops)
|
||||
r.selDragBody.Add(gtx.Ops)
|
||||
bc.Pop()
|
||||
r.selDragsOn[2] = true
|
||||
}
|
||||
r.drawHandle(gtx, sx, sy, lineH)
|
||||
r.drawHandle(gtx, ex, ey, lineH)
|
||||
} else {
|
||||
// Caret drag (long press on blank space): a single handle on the caret.
|
||||
cx, cy := handleAt(cursorPos)
|
||||
registerDrag(&r.selDragCaret, cx, cy)
|
||||
r.selDragsOn[3] = true
|
||||
r.drawHandle(gtx, cx, cy, lineH)
|
||||
}
|
||||
}
|
||||
|
||||
textClip.Pop()
|
||||
r.displayLineCount = lineCount
|
||||
// Store captured layout; derive lastLineY from it.
|
||||
|
|
@ -629,6 +887,14 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
}
|
||||
}
|
||||
|
||||
// drawHandle draws a selection handle: a short vertical stem with a filled
|
||||
// square foot at the line's bottom (v1 approximation of Android's circle).
|
||||
func (r *Renderer) drawHandle(gtx layout.Context, x, y, lineH Dp) {
|
||||
col := Color{R: 51, G: 153, B: 255, A: 255}
|
||||
r.drawBg(gtx, Region{X: x - 1, Y: y, W: 2, H: lineH}, col)
|
||||
r.drawBg(gtx, Region{X: x - 5, Y: y + lineH - 2, W: 10, H: 10}, col)
|
||||
}
|
||||
|
||||
func (r *Renderer) drawPng(gtx layout.Context, img image.Image, reg Region, width, height Dp) {
|
||||
if img == nil {
|
||||
return
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user