Three user-reported selection bugs, one root cause each:
1. Start handle ungrabbable at line start. Two interacting causes:
a) The 48dp grab box straddles two visual lines; a finger in the
lower half mapped (by y-to-line) to the neighbouring line, whose
byte past the other handle clamped to a zero-length selection ->
cleared on the first drag event. The cleared selection
un-registered the drag op, so the router silently stopped
delivering drag events (the observed 'stream cutoff'). Fix:
handle drags now project the finger's x onto the anchor's own
visual line (visualLineOfByte + textPosOnLineAtX); the anchor
never crosses lines during a handle drag.
b) A horizontal flick from the line-start handle (screen x~26px)
started the system back gesture, which cancelled the touch
stream. Fix: report the handle grab rects as system gesture
exclusion rects (setSystemGestureExclusionRects, API 29+),
marshalled to the UI thread via a PadExcl smali Runnable
(generated identically by build_emu.sh/build_phone.sh).
2. End-handle drag downward made the menu chase the finger and cover
the selection. Fix: the menu anchors to the STABLE end of the
selection (the end not being dragged), so it stays parked by the
selection start, clear of the finger and the highlighted text.
3. Menu above the selection vanished permanently when the selection
was extended onto the top line. Fix: off-window anchors no longer
hide the menu while any part of the selection is visible (keep-last
rect, clamped); hiding happens only for fully off-window selections.
Also: registerDrag simplified (single shared drag path, body before
handles in z-order), debug logging removed, regression tests
(mutation-verified) for line projection and menu anchoring, docs
section 17. Verified on device: start-handle drag shrinks the word
without clearing or triggering back navigation; end-handle vertical
drag leaves menu/highlight/handles undisturbed; menu stays visible
with the selection at the top line.
339 lines
11 KiB
Go
339 lines
11 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()
|
|
|
|
// lastExcl tracks the selection-handle grab boxes last sent to
|
|
// SetGestureExclusions (Android: keeps the system back gesture from
|
|
// stealing drags that start on an edge handle). Only the JNI call when
|
|
// the set changes — the rects move every frame while a selection is
|
|
// visible/scrolling, but identical repeats are skipped.
|
|
var lastExcl [][4]int
|
|
|
|
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
|
|
// User font-size setting: the shaper draws baselines in sp, so the
|
|
// rendered line pitch in density-dp is scaled by this factor. Logic
|
|
// bookkeeping (tap mapping, window start, scroll clamp) must follow
|
|
// it (EffectiveLineHeight).
|
|
newFontScale := float32(1)
|
|
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
|
|
newFontScale = gtx.Metric.PxPerSp / 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
|
|
}
|
|
curFontScale := frame.FontScale // 0 until the logic has the value
|
|
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()
|
|
}
|
|
// Selection-handle system-gesture exclusions (Android): forward the
|
|
// frame's handle grab boxes when they changed. Runs on the UI
|
|
// thread (the frame pump is), as View methods require.
|
|
if excl := renderer.GestureExclusions(); !exclEqual(excl, lastExcl) {
|
|
lastExcl = excl
|
|
SetGestureExclusions(excl)
|
|
}
|
|
|
|
// 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 || newFontScale != curFontScale {
|
|
logic.ConfigChan() <- editor.ScaleEvent{Scale: newScale, FontScale: newFontScale}
|
|
}
|
|
if len(events) > 0 {
|
|
logic.InputChan() <- events
|
|
}
|
|
if sendQuery {
|
|
logic.SearchQueryChan() <- newQuery
|
|
}
|
|
logic.LayoutChan() <- ui.LayoutFeedback{
|
|
GlyphLayout: glyphLayout,
|
|
WindowText: frame.WindowText,
|
|
WindowStartByte: frame.WindowStartByte,
|
|
WindowStartLine: frame.WindowStartLine,
|
|
EditSeq: frame.EditSeq,
|
|
}
|
|
default:
|
|
handleEvent(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// exclEqual reports whether two exclusion-rect sets are identical.
|
|
func exclEqual(a, b [][4]int) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|