Pad/cmd/pad/main.go
Greg Pomerantz 2a16c0017c 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.
2026-08-17 08:57:55 -04:00

297 lines
9.4 KiB
Go

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"
"gioui.org/widget"
"pad/internal/editor"
"pad/internal/io/pool/real"
"pad/internal/perf"
"pad/internal/ui"
)
func main() {
go func() {
w := new(app.Window)
w.Option(app.Title("Pad"))
w.Option(app.Size(unit.Dp(390), unit.Dp(844)))
if err := run(w); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
app.Main()
}
func run(w *app.Window) error {
var ops op.Ops
shaper := text.NewShaper(text.WithCollection(gofont.Collection()))
// Determine root directory for real filesystem
rootDir := flag.String("root", startpath, "root directory for the filesystem")
flag.Parse()
// Initialize the real filesystem rooted at the system root "/"
// so that the browser can navigate the entire system.
fs := real.NewRealFileSystem("/")
// Get the absolute path of the startup directory
startAbs, err := filepath.Abs(*rootDir)
if err != nil {
startAbs = *rootDir
}
log.Printf("using filesystem at / (startup directory: %s)", startAbs)
logic := editor.NewLogic(fs, startAbs, OpenFile)
renderer := ui.New(ui.Theme{FontSize: 14}, shaper)
// In-app frame profiler (default off). Enabled by the presence of a marker
// file so the shipping APK needs no rebuild to toggle profiling. When on,
// it records logic-frame cadence plus scroll/visible-range context to a CSV
// and logs rolling percentiles; it also enables the scroll-jump debug poller.
const perfDir = "/storage/emulated/0/PadPerf"
var prof *perf.Profiler
var perfOn bool
var presentN int
var presentStart time.Time
// shiftDown tracks the hardware shift key on the MAIN goroutine.
// On Android Gio's JNI bridge drops modifier state (GioView.onKeyEvent
// never reads event.getMetaState), so key.Event.Modifiers is always 0 and
// shift+arrow is indistinguishable from a plain arrow. Gio does deliver
// the NameShift press/release as plain key.Events, so we track them here
// and attach the state to the ui.KeyEvent we forward.
var shiftDown bool
if _, err := os.Stat(filepath.Join(perfDir, "enable")); err == nil {
prof = perf.New(true, perfDir, "logic_frames.csv")
editor.PerfRecord = func(rec editor.ProbeRecord) {
prof.Record(perf.Ctx{
Page: rec.Page,
ScrollDP: rec.ScrollDP,
MaxScrollDP: rec.MaxScrollDP,
TotalLines: rec.TotalLines,
VisStart: rec.VisStart,
VisEnd: rec.VisEnd,
})
}
logic.EnableDebugCmdPoll(perfDir)
perfOn = true
log.Printf("PERF: enabled (dir=%s)", perfDir)
}
// The search bar's widget.Editor is owned by the MAIN goroutine: Gio
// mutates it during draw, and the logic goroutine must never touch it
// (architecture.md §1). Its text is forwarded to the logic goroutine via
// SearchQueryChan; the logic only stores the result in Browser.Query.
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
var frame editor.Frame
go frameReceiver(w, &mu, &frame, logic.FrameChan())
go logic.Run()
for {
switch e := w.Event().(type) {
case app.DestroyEvent:
if prof != nil {
prof.Stop()
}
logic.Shutdown()
return e.Err
case app.ConfigEvent:
// ConfigEvent: raw pixel dimensions only.
logic.ConfigChan() <- editor.ConfigEvent{
PixelWidth: e.Config.Size.X,
PixelHeight: e.Config.Size.Y,
}
case app.FrameEvent:
if perfOn {
presentN++
if presentStart.IsZero() {
presentStart = time.Now()
}
if time.Since(presentStart) >= 2*time.Second {
dt := time.Since(presentStart).Seconds()
log.Printf("PERF-PRESENT frames=%d fps=%.1f", presentN, float64(presentN)/dt)
presentN = 0
presentStart = time.Time{}
}
}
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()
curScale := frame.Scale
if curScale <= 0 {
curScale = 1 // no frame yet
}
renderer.Draw(gtx, frame.Elems, curScale)
glyphLayout := renderer.GlyphLayout()
// Send search query update to the logic goroutine when it changes.
// The logic goroutine handles filtering and triggers a new frame.
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
if focusedID == "" {
// No focused input: drop any stale shift state (a release event
// for a key held across focus loss may never arrive).
shiftDown = false
}
if focusedID != "" {
if reg, ok := renderer.Keys[focusedID]; ok {
// Use key.Filter to only receive events destined for the focused element.
// We need both Key events (for arrow keys) and Edit events (for text input).
// In Gio, key.Filter covers key presses, while key.FocusFilter covers
// focus and text edit events.
//
// On mobile the window layer wraps plain arrow-key PRESSES in
// input.SystemEvent (it wants to use them for focus navigation) and
// such events match only filters that name the key explicitly. Querying
// the four arrow names below therefore (a) makes the presses deliverable
// and (b) suppresses the focus-move side effect: a matched event makes
// WakeupTime report handled, which skips the window's moveFocus call.
for {
// Filter for key events and focus/edit events targeted at focusedID
evt, ok := gtx.Event(
key.Filter{Focus: focusedID},
key.Filter{Focus: focusedID, Name: key.NameLeftArrow},
key.Filter{Focus: focusedID, Name: key.NameRightArrow},
key.Filter{Focus: focusedID, Name: key.NameUpArrow},
key.Filter{Focus: focusedID, Name: key.NameDownArrow},
key.FocusFilter{Target: focusedID},
)
if !ok {
break
}
switch k := evt.(type) {
case key.Event:
if k.Name == key.NameShift {
// Track shift; never forward it as a content key.
shiftDown = k.State == key.Press
continue
}
if k.State == key.Press {
// ui.KeyEvent carries modifier state so handlers
// can distinguish shift+arrow (extend selection)
// from plain arrow (move cursor). On Android the
// Modifiers field is always empty, hence the shiftDown OR.
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: ui.KeyEvent{
Name: k.Name,
Shift: k.Modifiers.Contain(key.ModShift) || shiftDown,
},
})
}
case key.EditEvent:
events = append(events, ui.InputEvent{
Handler: reg.Handler,
Data: k,
})
case key.SnippetEvent:
// Handle snippet event if necessary, or ignore
default:
log.Printf("unexpected event type: %T", k)
}
}
}
}
e.Frame(&ops)
mu.Unlock()
if newScale != curScale {
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale}
}
if len(events) > 0 {
logic.InputChan() <- events
}
if sendQuery {
logic.SearchQueryChan() <- newQuery
}
logic.LayoutChan() <- glyphLayout
default:
handleEvent(e)
}
}
}
func frameReceiver(w *app.Window, mu *sync.Mutex, frame *editor.Frame, frameChan <-chan editor.Frame) {
for {
f := <-frameChan
mu.Lock()
*frame = f
w.Invalidate()
mu.Unlock()
}
}