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.
495 lines
18 KiB
Go
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()
|
|
}
|
|
}
|