Pad/cmd/pad/main.go
Greg Pomerantz 180fa966c8 Pinch-to-font-size (continuous, content-point pinned) + IME-open scroll fix
Two feature bodies accumulated in the working tree:

1. Pinch to change the app font size, continuously (no snapping):
   - internal/ui/pinch_tracker.go: logic-free touch state machine.
     Two-mover formation (the resting palm can land first or last;
     movement is the only signal valid for both), pair = the mover
     pair whose distance changed most, baseline = press distance
     (formDist), lazy pending releases, survivor-scroll forwarding
     after a pair break. Robust to ~1 fps frames: a whole pinch can
     land in one drain (formDist/brokeFactor/lazy releases).
   - render.go: pinch probe (raw pointer events) + grab lifecycle so
     the pair is exclusive (scroll sees nothing of the pair) and the
     survivor's finger keeps working as a scroll after the pinch.
   - state.go/logic.go/session.go/frame.go: app-local float font
     scale, content-point pin (buffer byte + offset from baseline,
     not a layout point, so rewrap keeps the same character under
     the center), restore/font pins, session persistence.
   - pinch_test.go, pinch_font_test.go, tag_identity_test.go,
     real_draw_probe_test.go: unit + real-Renderer/real-Router tests.

2. Soft keyboard must not shift content:
   - Root cause: gioui.org/app calls Router.RevealFocus on any frame
     the viewport shrinks (IME open under adjustResize) and
     synthesizes a pointer.Scroll nudge aimed at the focused field's
     stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
     content jump.
   - Fix: main.go flags the shrink frame; render.go drains that one
     synthetic scroll for the gesture's tag before Update (scroll-
     range clamping cannot work: the router UNIONs ranges across
     frames). Finger scroll (pointer.Drag) and the flinger are
     untouched. reveal_focus_drain_test.go reproduces RevealFocus at
     the router level and verifies the drain + zero delta.

Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
2026-08-23 09:00:51 -04:00

495 lines
18 KiB
Go

package main
import (
"encoding/json"
"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)
activeLogic = logic
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)
// The find bar's (in-file search) widget.Editor is main-owned the same
// way; its text is forwarded via FindQueryChan (editor.findSetQuery).
var findEditor widget.Editor
renderer.RegisterGioEditor("find_bar", &findEditor)
// Relaunch state restoration (spec §7): a tiny JSON file holds the last
// file, cursor, scroll, selection and find bar state. The cmd layer owns
// the file (sessionFilePath, per platform); the logic layer owns the
// snapshot (editor.SessionState) and calls the saver rate-limited and at
// Shutdown. Restoring re-opens the last file straight into the editor.
sessPath := sessionFilePath()
logic.SetSessionSaver(newSessionSaver(sessPath))
if sess, ok := loadSession(sessPath); ok {
log.Printf("restoring session: file=%s cursor=%d scroll=%v find=%q", sess.File, sess.Cursor, sess.Scroll, sess.FindQuery)
logic.BeginRestore(sess)
// The find bar's input is a main-owned widget and the input source of
// truth: seed it with the restored query so its first frame matches
// the logic-side query (an empty widget would forward "" and clear
// the restored query).
if sess.FindQuery != "" {
findEditor.SetText(sess.FindQuery)
}
}
// 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
// findBarFocused tracks whether the previous frame focused the find bar's
// main-owned input, so main hands key focus to it exactly once per open
// (see the focus handoff below).
var findBarFocused bool
// lastFindClearSeq is the FindClearSeq value whose wipe has already been
// applied to the main-owned find input (edge-triggered, see
// Frame.FindClearSeq).
var lastFindClearSeq int
// lastFrameW/H hold the previous FrameEvent's window size (px); 0 = no
// frame yet. Used to spot shrink frames (see ZeroWheelScroll below).
var lastFrameW, lastFrameH int
for {
e := w.Event()
switch e := e.(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
// App-local pinch font scale for the editor text (1.0 = default).
renderer.SetAppFontScale(frame.AppFontScale)
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
// Mirror each NEW find-clear (the bar's X button) into the main-
// owned widget input exactly once. Edge-triggered on ClearSeq, NOT
// "FindQuery==\"\" && widget has text": that level check also fired on
// stale frames between the user's typing and the logic storing it,
// wiping the input while typing.
if frame.FindClearSeq > lastFindClearSeq {
lastFindClearSeq = frame.FindClearSeq
findEditor.SetText("")
w.Invalidate()
}
newFind := findEditor.Text()
sendFind := newFind != frame.FindQuery
// On a frame that shrinks the window (the IME opening under
// adjustResize), Gio's window synthesizes a scroll-to-focus
// pointer.Scroll via RevealFocus — it reads the focused field's
// stale pre-resize bounds and nudges the editor content. Flag the
// frame so CheckGestures drains that one synthetic event before the
// scroll gesture consumes it (Renderer.ZeroWheelScroll); finger
// scroll and the flinger are unaffected, and normal frames are
// untouched.
renderer.ZeroWheelScroll = lastFrameH > 0 &&
(e.Size.Y < lastFrameH || e.Size.X < lastFrameW)
lastFrameW, lastFrameH = e.Size.X, e.Size.Y
events := renderer.CheckGestures(e.Source, gtx.Metric)
renderer.ZeroWheelScroll = false
// 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
// Hand key focus to the find bar's main-owned input the moment the
// find bar opens: the search-icon tap never touches the widget, and
// widget.Editor only grabs key focus on its own click (gioui.org/
// widget/editor.go). The widget's FocusEvent handler raises the soft
// keyboard on focus gain, so the keyboard follows automatically. On
// the inverse transition the editor's TextField re-issues its own
// key.FocusCmd (the renderer clears the focus dedup on frames where
// no logic field is focused), so nothing is needed on close.
if focusedID == "find_bar" && !findBarFocused {
gtx.Execute(key.FocusCmd{Tag: &findEditor})
w.Invalidate() // next frame: the widget sees the FocusEvent
}
findBarFocused = focusedID == "find_bar"
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
case key.FocusEvent:
// Focus gain/loss on the key queue (emitted when key.FocusCmd is
// issued, i.e. on focus transitions). Nothing to do: the logic
// layer already owns focus state.
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
}
if sendFind {
logic.FindQueryChan() <- newFind
}
// Skip layout feedback for frames built before the window size
// was known (frame.ViewportDegenerate, set at frame-build time —
// feedback delivery lags shaping by a frame, so checking the
// current size here would miss them): a zero-width shape wraps
// every line into many visual lines, and feeding those counts
// back would poison the WrapIndex for the window's lines
// (applied once, corrected only if those lines are re-shaped at a
// real width — a restored scroll that moves the viewport away
// never re-shapes them, and the poisoned counts then map a
// legitimate scroll offset to the wrong line).
if !frame.ViewportDegenerate {
logic.LayoutChan() <- ui.LayoutFeedback{
GlyphLayout: glyphLayout,
WindowText: frame.WindowText,
WindowStartByte: frame.WindowStartByte,
WindowStartLine: frame.WindowStartLine,
EditSeq: frame.EditSeq,
ScrollOffset: frame.ScrollOffset,
}
}
default:
handleEvent(e)
}
}
}
// exclEqual reports whether two exclusion-rect sets are identical.
// newSessionSaver builds the relaunch-session file writer registered with
// the logic goroutine (spec §7). The file is a tiny JSON snapshot; a torn
// write is rejected by loadSession's parse on the next launch, so a plain
// write is safe (no temp+rename needed).
func newSessionSaver(path string) func(editor.SessionState) {
return func(s editor.SessionState) {
b, err := json.Marshal(s)
if err != nil {
log.Printf("session: marshal: %v", err)
return
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
log.Printf("session: mkdir: %v", err)
return
}
if err := os.WriteFile(path, b, 0o600); err != nil {
log.Printf("session: write: %v", err)
}
}
}
// loadSession reads and sanity-checks the relaunch session file (spec §7).
// ok=false when there is no session, it is corrupt, or nothing is restorable
// — in which case the app starts in the browser as before.
func loadSession(path string) (editor.SessionState, bool) {
b, err := os.ReadFile(path)
if err != nil {
return editor.SessionState{}, false
}
var s editor.SessionState
s.ScrollLine = -1 // pre-line session files lack the key; 0 would restore to the top
if err := json.Unmarshal(b, &s); err != nil {
return editor.SessionState{}, false
}
// A partial/corrupt file must not restore garbage positions.
if s.Cursor < 0 {
s.Cursor = 0
}
if s.Scroll < 0 {
s.Scroll = 0
}
if s.SelStart < -1 || s.SelEnd < -1 || s.SelEnd <= s.SelStart {
s.SelStart, s.SelEnd = -1, -1
}
if s.FindCurByte < -1 {
s.FindCurByte = -1
}
if s.ScrollLine < -1 {
s.ScrollLine = -1
}
if s.ScrollSub < 0 {
s.ScrollSub = 0
}
if s.File == "" {
return editor.SessionState{}, false
}
return s, true
}
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()
}
}