Compare commits

...

10 Commits

Author SHA1 Message Date
06b1444207 gofmt: format all remaining files with the go1.27 toolchain
The tree was formatted with an older gofmt; go1.27's gofmt additionally
wants: EOF exactly one newline (no trailing blank lines), imports sorted
alphabetically within a block, mixed-precedence binary expressions
re-spaced for grouping ((a+b)/c), single-field composite literals
un-aligned, adjacent one-line method signatures aligned, and one-line
bodies containing a compound statement expanded. Applied repo-wide
(31 files under internal/); pure formatting, no semantic changes —
build and the full test suite pass.
2026-08-23 10:03:27 -04:00
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
0f1b6e6290 Hold the restored scroll on its line while wrap counts settle (spec §2.4)
On device (Pixel 9 Pro) the relaunch landed further UP than the saved
position: while the restore scroll is still armed (scale + size + content
can take ~600 ms), the top-of-file window is what gets rendered, and its
shaping feedback — real wrap counts for lines ABOVE the restored line —
lands before or just after the offset is applied. The counts are correct
data, but they change V(line), so the line-derived offset (synthesized
for the all-estimate index) maps to a shallower line and the viewport
drifts up; the save then persists the drifted line and every subsequent
relaunch lands there.

Fix: the restore pins the logical line. Until the restored window's own
shaping arrives (or a 2 s timeout, or the user scrolls / a search jumps),
every accepted wrap correction re-derives the offset as
VisualsBefore(line)*lh + sub under the corrected index. The pin refresh
does not clamp to MaxScroll: the correction just grew the index, so the
pre-layout clamp value is stale and would under-clamp the re-derived
offset (the layout of the emitted frame clamps to the fresh value).

Also: the apply-time offset is mapped through VisualsBefore under the
current index (identical to line*lh while the index is all estimates),
so pre-apply corrections are absorbed instead of gated away.

Verified on device: saved line 420 -> restored 420 (was 333); saved 573
(near EOF, clamp territory) -> restored 573. New e2e regression
TestRestore_LinePinHoldsAcrossLateWrapFeedback replays the late
top-window feedback and fails pre-fix (window drifts 200 -> 66).
2026-08-20 21:03:56 -04:00
6968f6a284 Restore scroll by logical line, not pixel offset (wrap-aware)
On device: scroll far down a wrapped file, relaunch, and the app lands
further DOWN than where the user left off — the deeper the scroll, the
further off.

Root cause: the persisted Scroll is a pixel offset in VISUAL-line space.
Restoring maps it through the WrapIndex (scrollDecompose -> LineForVisual),
but on relaunch every count is the estimate (1) until the line is shaped,
and shaping covers the visible window only — the lines ABOVE the restored
viewport are never shaped. With all-ones counts LineForVisual maps the
offset 1:1, landing a logical line deeper by every wrapped continuation
above the viewport, and the state is stable (the under-counted lines never
re-enter the window), so it never self-corrects.

The snapshot now persists wrap-independent coordinates: the logical line
at the viewport top (derived with the same mapping the layout uses,
against the current index, so it is exactly the shown line) plus the
sub-line remainder. The restore re-derives the offset as line*lh + sub,
which maps to the saved line under any wrap state (all-ones or populated).
The raw Dp offset is kept for pre-line-coordinate session files
(loadSession defaults the missing key to -1; BeginRestore rejects the
ambiguous zero value: a genuine line-0 snapshot always has Scroll < lh).

TestRestore_ScrollSurvivesWrapState reproduces it: 150 lines recorded as
wrapped x3, viewport at logical line 200 (visual 500); a relaunch with a
fresh WrapIndex must land the window on line 200. Fails pre-fix (window at
line 254, i.e. deeper) and passes with the fix.

Docs: spec §2.4 (line-based scroll persist + current save policy),
architecture §6.7 (why the offset is unrestorable by re-mapping).
2026-08-20 19:31:51 -04:00
fdbffc9f5c Fix SIGABRT on activity destroy: null view in registerFragment
Crash on device (Pixel 9 Pro, Android 17) when swiping the app away
from recents:

  JNI DETECTED ERROR: java_object == null in call to GetObjectClass
  #06 libgio.so (registerFragment+104)

Root cause: Gio's window.detach sends an EMPTY AndroidViewEvent
(View == 0) as its detach signal (os_android.go: window.detach ->
processEvent(AndroidViewEvent{})), which fires when the GioView is
destroyed — i.e. the activity going away on a recents-wipe. Our
handleEvent passed that null ref straight into registerFragment,
whose GetObjectClass(null) aborts. Pre-existing latent bug; the
emulator never delivered a detach event in testing (home keeps the
view attached, force-stop kills before the event dispatches).

Guard on both sides: handleEvent ignores the View == 0 detach
signal (a re-attach arrives as a fresh event with a live view), and
registerFragment returns early on a null view as defense in depth.

Verified on the crashing device: recents swipe now closes the app
cleanly, crash buffer empty.
2026-08-20 18:49:00 -04:00
2bdff5c8e8 Flush the session snapshot on activity onStop (Android)
The OS provides no user-space hook for a process kill, but the
activity onStop fires on every 'going away' transition the framework
still controls: entering recents (the swipe-wipe path), app switch,
and home. Recents-wipe then kills the process right after onStop
returns, so that moment is the last reliable flush.

- Logic.FlushSession (any goroutine, buffered, non-blocking) ->
  flushSessionSave on the owner: persist the snapshot now, bypassing
  the rate limit (still honoring the restore-pending suppression).
  saveSessionIfChanged's persist tail is deduplicated into writeSession.
- JNI: GioActivity.onStop (patched into the smali by the build
  scripts, in sync) now calls the static native padFlushSession, which
  maps to the pad_flush_session cgo export.

Verified on emulator: the flush fires on home/app-switch and when the
app is backgrounded into recents before a kill; a state change made
inside the 250ms rate window and then backed out of the app persists
the latest position. A hard kill while foregrounded (force-stop,
memory pressure) still has no hook — the immediate edit saves plus the
250ms rate window bound that loss.
2026-08-20 18:24:43 -04:00
2eb18b5c70 Persist edits immediately so a fast app-kill loses no state
Reproduced on device: open a file, scroll, long-press-highlight a
word, then recents-wipe the app ~1s after the highlight. The session
file held the file but lost the selection and cursor: the 1s
rate-limit had not elapsed since the previous save, so the final
changes never flushed before the process died (a recents-wipe is a
kill, not a clean shutdown, so Shutdown's final save never runs).

Two changes in saveSessionIfChanged:

- Urgent path: save immediately when the file changes or a selection
  appears/vanishes. These are low-frequency, high-value changes
  (the user just opened a file or highlighted/cleared text), and they
  are exactly what 'where I left off' means.
- sessionSaveInterval 1s -> 250ms: bounds how stale scroll/cursor can
  be at an unlucky kill. The file is a few hundred bytes, so a few
  small writes a second during active scrolling is negligible.

TestRestore_UrgentSaveOnSelection drives the kill race: a selection
made right after the file-open save must land in the saver without
waiting out the interval (fails with the urgent path disabled).
2026-08-20 18:06:29 -04:00
03595af27d Fix relaunch-restore bugs found on device (spec §7 verification)
On-device runs exposed three bugs the e2e suite could not (it never
feeds layout feedback, and runs headless without size events):

1. WrapIndex poisoning from zero-width shapes. The first frames are
   built before the window size is known (px=0x0); the renderer shapes
   the editor window at zero width, where every line wraps into many
   visual lines. When that feedback arrives, applyWrapCounts writes the
   inflated counts to the window's lines. Normally the app stays on
   those lines and re-shapes them at a real width, which corrects the
   counts before anyone notices. A restored scroll moves the viewport
   away instead, so the poisoned counts persist and map the restored
   scroll offset to the wrong line (2000 landed on line 11 of 200).
   The frame now carries ViewportDegenerate (set at frame-build time,
   since feedback delivery lags shaping by a frame), and the main loop
   drops layout feedback for such frames.

2. Session saving suppressed forever after a successful restore.
   saveSessionIfChanged suppresses saves while l.session is set, and
   only abortRestore cleared it — the success path never did, so an
   app restored from a session never persisted new state. The
   snapshot has fully landed once the cursor/selection/find have
   landed with the content and the armed scroll has landed (or there
   was none); clear l.session at both points.

3. gofmt on restore_test.go (comment alignment).
2026-08-20 17:31:55 -04:00
e3690d6b98 Restore last file, cursor, scroll, selection and find state on relaunch (spec §7)
Persist a tiny JSON snapshot (SessionState) written by the cmd layer
($HOME/.pad/session.json off-Android, /storage/emulated/0/Pad/ on
Android) and call the logic-owned snapshot rate-limited (<=1/s, on
change) from emitFrame plus unconditionally at Shutdown. On launch the
cmd layer hands the snapshot to Logic.BeginRestore before Run; the
file re-opens through the normal openFile path and lands straight on
the editor page.

- Cursor/selection land with the content, clamped to a shrunk file
  (path-guarded so a late result for a replaced file cannot apply the
  snapshot to the wrong buffer); a missing file falls back to the
  browser.
- Scroll is applied only after the first ScaleEvent has been laid out:
  the size ConfigEvent precedes it and the one-way MaxScroll clamp in a
  wrong-unit layout would corrupt the offset (found by e2e).
- Find: query + open/closed + current match are persisted; results are
  regenerated by re-scanning and the saved current match is re-selected
  by byte offset (Find.Restoring/RestoreMatch) without re-scrolling the
  restored viewport. A closed-bar query re-scans on the next bar open
  instead (an eager scan would be dropped and leave Scanning stuck).
- The main-owned find_bar widget is seeded with the restored query so
  its first frame matches the logic-side query.

Docs: spec.md gains §2.4 and drops the §7 row; invariant 5 updated;
architecture.md gains §6.7. Tests: 7 e2e tests covering cursor/scroll/
selection restore, clamping, missing-file fallback, find-bar restore
(open/closed), and the saver/shutdown persist paths.
2026-08-20 16:01:02 -04:00
f31f849665 Make the release process script-driven and documented
The release flow, install policy, and on-device rules previously lived in
session context. Now:

- scripts/release.sh: the executable release — gate 1 static checks,
  gate 2 full go test (incl. TestNoFramesWhileIdle), gate 3 frame-regression
  profile on the emulator, build (SKIP_CHECK=1 avoids double static checks),
  then install to EVERY connected device (emulators and phones; the
  wireless-debugging serial changes per reconnect, the loop sidesteps it).
  Any gate failure aborts before install; dirty tree is warned, not fatal.
- doc/release.md: the process, the install-to-all-devices default, the rule
  that a phone is INSTALL-ONLY during a release (on-device
  profiling/testing is diagnostic and needs explicit approval per run),
  failure-handling table, versioning gap (still gogio default 1.0.0.1), and
  emulator requirements.
- doc/README.md: release.md added to the doc table.
- architecture.md §11: pointer to the release flow.

Validated end-to-end: release.sh ran all three gates (PASS), built the APK,
and installed it on both the phone and the emulator in one command.
2026-08-20 14:54:48 -04:00
63 changed files with 5554 additions and 358 deletions

4
.gitignore vendored
View File

@ -13,6 +13,10 @@
cmd/pad/pad
cmd/pad/classes
# touchinject harness build artifacts (regenerated by build.sh)
tools/touchinject/build/
tools/touchinject/dex/
# IDE
.idea/
.vscode/

View File

@ -1,4 +1,5 @@
//+build !darwin !linux
//go:build !darwin || !linux
// +build !darwin !linux
package main
@ -17,22 +18,38 @@ import (
"unsafe"
"gioui.org/app"
"gioui.org/io/event"
_ "gioui.org/app/permission/storage"
"gioui.org/io/event"
"pad/internal/editor"
)
type JNIEnv = C.JNIEnv
var (
startpath="/storage/emulated/0/Notes"
jvm uintptr
theJVM *C.JavaVM
startpath = "/storage/emulated/0/Notes"
jvm uintptr
theJVM *C.JavaVM
)
func impl_start() { }
// activeLogic is set in main before the window loop: the onStop JNI hook
// (pad_flush_session, below) needs a handle to the logic it serves.
var activeLogic *editor.Logic
func impl_start() {}
func handleEvent(e event.Event) {
switch e := e.(type) {
case app.AndroidViewEvent:
// View == 0 is Gio's DETACH signal (window.detach sends an empty
// AndroidViewEvent when the GioView is destroyed — e.g. the activity
// going away on a recents-wipe). There is nothing to register for a
// detached view; passing the null ref on would abort in JNI
// (GetObjectClass on null). A re-attach arrives as a fresh event
// with a live view.
if e.View == 0 {
return
}
theJVM = (*C.JavaVM)(unsafe.Pointer(app.JavaVM()))
RunInJVM(func(env *JNIEnv) {
C.registerFragment(env, (C.jobject)(unsafe.Pointer(e.View)))
@ -41,26 +58,26 @@ func handleEvent(e event.Event) {
}
func RunInJVM(f func(env *C.JNIEnv)) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var env *C.JNIEnv
var detach bool
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK {
panic(errors.New("runInJVM: AttachCurrentThread failed"))
}
detach = true
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
var env *C.JNIEnv
var detach bool
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK {
panic(errors.New("runInJVM: AttachCurrentThread failed"))
}
detach = true
}
if detach {
defer func() {
C.DetachCurrentThread(theJVM)
}()
}
f(env)
if detach {
defer func() {
C.DetachCurrentThread(theJVM)
}()
}
f(env)
}
// SetGestureExclusions forwards the selection-handle grab boxes (view-local
@ -68,6 +85,19 @@ func RunInJVM(f func(env *C.JNIEnv)) {
// an edge handle are not stolen by the system back gesture (see
// jni_android.c). It is called from the frame loop, which runs on the Android
// UI thread. No-op (C-side) before API 29.
// pad_flush_session is the cgo export behind GioActivity.onStop (the
// build script patches onStop into the smali and declares the matching
// static native method). The OS gives no user-space hook for the process
// kill that follows a recents-wipe, so onStop is the last reliable moment
// to persist the session snapshot.
//
//export pad_flush_session
func pad_flush_session() {
if activeLogic != nil {
activeLogic.FlushSession()
}
}
func SetGestureExclusions(rects [][4]int) {
if theJVM == nil {
return
@ -87,17 +117,24 @@ func SetGestureExclusions(rects [][4]int) {
})
}
// sessionFilePath is where the relaunch session file (spec §7) lives. The
// primary storage dir already carries the app's caches (.pad browser index
// dirs, PadPerf), and the app holds the storage permission.
func sessionFilePath() string {
return "/storage/emulated/0/Pad/session.json"
}
func OpenFile(path string) {
var env *C.JNIEnv
var detach bool
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res := C.GetEnv(theJVM, &env, C.JNI_VERSION_1_6); res != C.JNI_OK {
if res != C.JNI_EDETACHED {
panic(fmt.Errorf("JNI GetEnv failed with error %d", res))
}
if C.AttachCurrentThread(theJVM, &env, nil) != C.JNI_OK {
panic(errors.New("OpenFile: AttachCurrentThread failed"))
}
detach = true
detach = true
}
if detach {
@ -109,4 +146,3 @@ func OpenFile(path string) {
C.open_file_in_termux(env, cpath)
C.free(unsafe.Pointer(cpath))
}

View File

@ -1,19 +1,39 @@
//+build !android
//go:build !android
// +build !android
package main
import (
"os"
"pad/internal/editor"
"path/filepath"
"gioui.org/io/event"
)
var (
startpath="."
startpath = "."
)
func handleEvent(e event.Event) { }
// sessionFilePath is where the relaunch session file (spec §7) lives, off
// Android: $HOME/.pad/session.json, falling back to the temp dir when there
// is no home.
func sessionFilePath() string {
if d, err := os.UserHomeDir(); err == nil && d != "" {
return filepath.Join(d, ".pad", "session.json")
}
return filepath.Join(os.TempDir(), "pad", "session.json")
}
func OpenFile(path string) { }
func handleEvent(e event.Event) {}
func OpenFile(path string) {}
// SetGestureExclusions is a no-op off Android (system gesture exclusion
// rects are an Android API 29+ feature).
func SetGestureExclusions(rects [][4]int) {}
// activeLogic exists on the Android build (the onStop JNI hook, see
// impl_android.go); on other platforms it is unused but main assigns it.
var activeLogic *editor.Logic
var _ = activeLogic // keep staticcheck quiet on non-Android builds

View File

@ -15,13 +15,17 @@ static jobject g_view = NULL;
void
registerFragment(JNIEnv *env, jobject view) {
if (view != NULL) {
if (g_view == NULL) {
g_view = (*env)->NewGlobalRef(env, view);
} else if (g_view != view) {
(*env)->DeleteGlobalRef(env, g_view);
g_view = (*env)->NewGlobalRef(env, view);
}
if (view == NULL) {
// Detach signal (Gio sends an empty AndroidViewEvent when the
// view is destroyed). The Go side already filters these; this
// guard keeps the JNI calls safe if one ever slips through.
return;
}
if (g_view == NULL) {
g_view = (*env)->NewGlobalRef(env, view);
} else if (g_view != view) {
(*env)->DeleteGlobalRef(env, g_view);
g_view = (*env)->NewGlobalRef(env, view);
}
jclass cls = (*env)->GetObjectClass(env, view);
jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;");
@ -191,3 +195,14 @@ void SetGestureExclusions(JNIEnv *env, jint nrects, const jint *xyxy) {
(*env)->DeleteLocalRef(env, runnable);
(*env)->DeleteLocalRef(env, list);
}
// Java_org_gioui_GioActivity_padFlushSession
//
// Called from GioActivity.onStop (injected by the build script): persist
// the session snapshot before the process is likely killed. The Go side
// (pad_flush_session) only enqueues a request on the logic goroutine's
// channel, so this returns immediately on the UI thread.
void
Java_org_gioui_GioActivity_padFlushSession(JNIEnv *env, jclass cls) {
pad_flush_session();
}

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"flag"
"io"
"log"
@ -59,6 +60,7 @@ func run(w *app.Window) error {
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
@ -106,6 +108,25 @@ func run(w *app.Window) error {
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
@ -136,9 +157,13 @@ func run(w *app.Window) error {
// 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 {
switch e := w.Event().(type) {
e := w.Event()
switch e := e.(type) {
case app.DestroyEvent:
if prof != nil {
prof.Stop()
@ -216,6 +241,8 @@ func run(w *app.Window) error {
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.
@ -234,7 +261,19 @@ func run(w *app.Window) error {
}
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.
@ -346,12 +385,25 @@ func run(w *app.Window) error {
if sendFind {
logic.FindQueryChan() <- newFind
}
logic.LayoutChan() <- ui.LayoutFeedback{
GlyphLayout: glyphLayout,
WindowText: frame.WindowText,
WindowStartByte: frame.WindowStartByte,
WindowStartLine: frame.WindowStartLine,
EditSeq: frame.EditSeq,
// 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)
@ -360,6 +412,65 @@ func run(w *app.Window) error {
}
// 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

View File

@ -1,6 +1,6 @@
# Pad documentation
Three documents, kept at the level of *what, why, and invariants* — not
Four documents, kept at the level of *what, why, and invariants* — not
line-by-line code — so they stay true as the implementation evolves.
| Doc | What it is |
@ -8,6 +8,7 @@ line-by-line code — so they stay true as the implementation evolves.
| [`spec.md`](./spec.md) | What the app **actually does**, measured performance, the code layout, and an explicit list of deferred features. |
| [`architecture.md`](./architecture.md) | How it works: single-owner concurrency model, channel topology, Frame handoff contract, ownership rules, editor/browser/render internals. |
| [`development_plan.md`](./development_plan.md) | The active plan: completed phases, remaining work, and the on-device observation loop. |
| [`release.md`](./release.md) | The release process (`scripts/release.sh`): the gates, the install-to-all-devices policy, and the rule that on-device profiling/testing is diagnostic and needs explicit approval. |
## Package inventory
@ -110,6 +111,23 @@ There are TWO independent scale factors, not one:
(window start, sub-line remainder, tap mapping, scroll clamp, caret,
handles, highlight). Change it and the line pitch on screen changes
(57 px/line at 1.3× vs 44 px/line at 1.0× on this AVD).
- **App-local pinch font scale** (a third factor, in-app only): a two-finger
pinch in the editor multiplies a continuous float32 scale (1.0 default,
clamped 0.53.0, never rounded) on top of the two factors above; the
rendered line pitch is `16.8 × fontScale × appFontScale` dp. The logic
side folds it into `EffectiveLineHeight()` (system × app) and the
renderer multiplies the editor's sp size by it. The pinch CENTER is the
anchor: the logic captures the **content point** there (the glyph byte +
offset from its baseline, with a line/fragment/sub-line fallback) and
re-anchors it under every newly shaped layout — including the re-wrap a
few frames after the font change — so the character under the fingers
holds still, not merely its (rewrap-moved) visual line. It is persisted in
the relaunch session (`AppFontScale`), and
the session's `ScrollSub` is stored as a *fraction* of the line height so
scroll restore is font-independent. Test hook: `scripts/emu.sh cmd pinch
<F>` (relative, anchored at the editor region center) / `fontsize <F>`
(absolute, top-anchored) drive the same `HandleFontPinch` path a real
pinch delivers.
On-device verification notes:

View File

@ -292,6 +292,34 @@ Only the visible byte range is shaped and drawn each frame:
- Max scroll is `TotalVisuals()·lh regionH + lh/2` with the
font-scale-effective line height; it grows incrementally as shaped
counts arrive (pre-shaping it equals the no-wrap estimate).
- **Relaunch snapshot (spec §2.4):** the counts are process-local (rebuilt
from the all-ones estimate on relaunch; only the visible window is ever
shaped), so a persisted pixel scroll offset cannot be restored by
re-mapping — with every count above the restored viewport at the
estimate, `LineForVisual` maps the offset 1:1 to a logical line deeper
by all the wrapped continuations above it, with no self-correction
(those lines never enter the window). The snapshot therefore persists
the wrap-independent coordinates: the logical line at the viewport top
(derived with the same scrollDecompose + LineForVisual mapping the
layout uses, against the CURRENT index) plus the sub-line remainder;
the restore re-derives the offset as `line·lh + sub` — mapped through
`VisualsBefore(line)` under the index as it stands at apply time. The
raw offset is kept as the fallback for pre-line-coordinate session
files. The pin is still needed after apply: on device the first frames
after relaunch render the TOP-OF-FILE window (the scroll is armed until
scale+size+content are all known, which can be ~600 ms), and that
window's shaping feedback — real wrap counts for the lines ABOVE the
restored line — lands before or just after the offset is applied.
Those counts are correct data, but they change `V(line)`: the offset
synthesized for the estimate index then maps to a shallower line (the
on-device "lands further up" report). So while the restore settles,
every accepted correction re-derives the offset as
`VisualsBefore(line)·lh + sub` (no MaxScroll clamp — the correction
just grew the index, so the pre-layout clamp value is stale; the
layout of the emitted frame clamps to the fresh one), and the pin
stands down when the restored window's own feedback arrives, after a
2 s timeout, or when the user scrolls or a search takes over the
viewport.
- **Font-scale axis.** The shaper draws baselines in sp, so on Android the
rendered line pitch in density-dp is `EditorLineHeight()*fontScale`
(`fontScale = Metric.PxPerSp/PxPerDp`, the user font-size setting).
@ -457,11 +485,45 @@ Only the visible byte range is shaped and drawn each frame:
- A browser tap sends the path on `OpenFileChan`. The logic goroutine creates
the `ChunkedBuffer`, dispatches `StatFile` (size guard) and the read +
`BuildLineIndex` tasks, switches `page` to the editor, and sets
`justOpenedAt`.
`justOpenedAt`. The relaunch restore (below) reuses the same `openFile`
path.
- **Opening-tap swallow:** the tap that opens the file is also delivered as an
editor tap in the same frame. A short time window (`justOpenedAt`) swallows
it so the viewport does not jump to the tapped (often EOF) position.
### 6.7 State restoration on relaunch (`session.go`)
- The restorable state is a plain comparable value, `SessionState` (last
file, cursor byte, scroll Dp, selection range, find query/visibility/
current-match byte). The search results themselves are not stored; they are
re-scanned on restore.
- **Ownership:** the logic goroutine owns snapshot content. The cmd layer
(`cmd/pad/main.go`) owns the JSON file and registers the writer via
`Logic.SetSessionSaver`; the owner invokes it rate-limited (≤ 1/s, only on
change) from `emitFrame` and unconditionally at `Shutdown` (post-exit,
single-threaded, next to `FlushAll`). Satisfies the single-owner rule:
the callback receives a value copy, and the file I/O is a tiny synchronous
write in the cmd layer's closure.
- **Restore:** the cmd layer reads the file at startup and calls
`Logic.BeginRestore(s)` BEFORE `Run()` (single-threaded window, like
`NewLogic` itself): it sets `Filename`, shows the editor page, and arms the
scroll. `Run()` then re-opens the file through the normal `openFile` path.
- **Stat success:** the find state lands (query, visibility, current-match
byte via `Find.Restoring`/`Find.RestoreMatch`, so the re-scan's first
result re-selects the saved match WITHOUT re-scrolling the restored
viewport).
- **Content arrival:** cursor/selection land, clamped to the file length
(path-guarded, so a late result for a replaced file cannot apply the
snapshot to the wrong buffer); the query re-scans only if the find bar
was open (a closed-bar scan would be dropped and leave `Scanning` stuck).
- **Scroll offset:** applied only after the first `ScaleEvent` has been
laid out: the size `ConfigEvent` precedes it, and a layout in between
computes the viewport in the wrong unit, whose one-way `MaxScroll` clamp
would corrupt the offset.
- **File gone** (stat failure): the restore is abandoned and the app lands
on the browser page with a clean editor. Any user open of another file
cancels an in-flight restore for the same reason as the path-guard.
## 7. Browser internals (`internal/browser`)
- `BrowserState` is **embedded by value** in `State` (single owner; no
@ -586,6 +648,10 @@ cannot measure this app because it renders into a `SurfaceView`).
app and seeds/removes a file in its storage, and the developer may be using
the phone while a release runs. It exists to investigate a suspected
frame/battery problem, not to certify a release.
The full release flow (gates → build → install to all connected devices,
and the policy that a phone is install-only during a release) is
`doc/release.md` + `scripts/release.sh`.
- The profiler is owned by the goroutine that creates it and is single-goroutine
(no locks). It does **not** `Sync()` the CSV per flush (only per row batch) to
avoid periodic fsync hitches in the logic path.

View File

@ -1048,3 +1048,298 @@ Verified on device: with the exclusion active, horizontal drags from the
left-edge start handle no longer trigger `startBackNavigation`
(`dumpsys window` shows the exclusion region; logcat shows zero
back-gesture previews).
## 18. Pinch-to-change-font-size, continuous (2026-08-22)
A two-finger pinch in the editor now changes the app's font size smoothly,
without snapping to whole points. The app-local scale is a float32
(default 1.0, clamped 0.53.0) layered on top of the system user font
setting; it is never rounded anywhere in the pipeline.
**Renderer (`internal/ui`).** Gio v0.10 has no two-finger pinch primitive,
so the Renderer owns a probe event tag clipped to the editor text region
(next to the long-press probe). It tracks the active pointers across frames
(window-px positions keyed by pointer ID) and emits one relative factor per
frame — `pinchDist(cur)/pinchDist(prev)` — as `ui.FontPinchEvent` to the
editor's new `ui.Pinch` interaction handler. `pinchDist` (two lowest-ID
pointers) is a pure function, unit-tested. While a pinch is active, scroll
emission is suppressed so the first finger does not drag the text.
`drawWrappedText` multiplies the editor's sp size by the frame's
`AppFontScale` (main goroutine feeds it via `SetAppFontScale` before
Draw); ascent/line-height/highlight/caret/handles all follow because they
derive from the same size.
**Logic (`internal/editor`).** `State.appFontScale` + `HandleFontPinch`
(multiply by the per-frame factor and clamp). The anchor is the pinch
CENTER, not the viewport top — and it is a **content point**, not a layout
point: `State.captureContentPin` names the glyph under the midpoint (the
ABSOLUTE buffer byte — the layout's `ByteOffsets` are window-relative, so
the capture adds `IMEWindowStartByte` — plus the point's offset from that
glyph's baseline), captured under the pre-change layout. A rewrap moves
the *text* of a visual line (the same fragment index holds different bytes
after the rewrap), so pinning (line, fragment) would leave a different
character at the center; naming the byte does not. The offset is applied
in two phases: (1) immediately, `rescaleScrollAnchored` rescales `S + m`
about the center (continuous, valid until rewrap lands); (2) on **every
newly shaped layout at the current scale** — the re-shape after the font
change and the rewrap corrections that follow it — `refineContentPin`
recomputes the offset from the pinned byte's fresh baseline:
`S' = vk·lh + Y + Dy m`, where `vk = VisualsBefore(WindowStartLine)` is
the window's FIRST visual line (the window top sits at content `vk·lh`,
NOT `floor(S/lh)·lh` — a different line whenever the viewport top lands
mid-way through a wrapped logical line) and `Y` is the byte's baseline in
the fresh layout (located by its absolute byte, window start from
`LayoutFeedback.WindowStartByte`). That lands the byte exactly on the
center and is a fixed point when the layout already agrees (no drift, no
oscillation). Two stale-data traps had to be closed: the scale change
**invalidates the last shaped layout** (`invalidateShapedLayout`) —
otherwise the next frame computes its window start with the OLD line
height and the NEW rescaled offset, a window ~10k lines off — and
`refreshFontPin` skips feedback shaped at a different scale (during a
pinch every frame changes the scale, so all but the latest feedback are
stale). While armed (2 s, refreshed by feedback) the pin rides every
frame; a (line, fragment, sub-line) anchor (`captureFontPin`/`applyFontPin`)
stands in for points off any glyph; an edit (EditSeq mismatch), a scroll,
or the timeout disarms it. `SetAppFontScale` (the `fontsize` debug
command) keeps the top-anchored behavior (no fingers to center on) and
invalidates the stale layout too. `EffectiveLineHeight()` is now system ×
app; every geometry consumer (window start, tap mapping, scroll clamp,
restore) was already routed through it. `Frame.AppFontScale` carries the
value to the renderer; `LayoutFeedback.ScrollOffset` carries the shaped
scroll back (used by the fallback path and diagnostics).
**Persistence.** The session snapshot gains `AppFontScale`, and
`ScrollSub` is now stored as a *fraction* of the line height (font-
independent; legacy Dp values > 1 are converted on restore).
**Testing.** `pinch_font_test.go` (continuous product of small factors,
clamp at both ends, center-anchor invariance, glyph hit-testing, content-
pin capture, the refine keeping the pinned BYTE on the center across a
rewrap that moves it to another fragment, the fixed-point property, the
(line, fragment) fallback, fragment clamp on pinch-out, bad-data no-op),
`pinch_test.go` (pinchDist/pinchMid geometry,
factor-series telescoping). On the emulator
(`scripts/emu.sh cmd pinch <F>` / `fontsize <F>` — one-shot commands that
drive the same `HandleFontPinch` path a real pinch delivers, since adb has
no two-finger input; `pinch` anchors at the editor-region center): five
×1.05 steps produced line pitches 44→46→49→51→54→56 px (autocorrelation-
measured) — continuous, no whole-point snapping; on a 40k-line wrapped
file scrolled to the middle, a pinch in/out cycle (×1.5 → ×0.75 →
×1.125) exercised the rewrap in BOTH directions (1→2 and 2→1 fragments
per line) and the pin converged to a fixed point within 23 layout-
feedback frames at every step, keeping the captured BYTE's line on the
region center (verified against the app's own geometry, not the pixels);
the ground-truth tap test (tap a line, type a marker, read the file)
passed at a 1.125× scale after two rewrapping pinches; `fontsize` keeps
the top anchor across a 1→2 rewrap; font scale, file, cursor and scroll
all survive a full restart; 0.5/3.0 clamps hold; one-finger scroll is
unaffected (and takes over the viewport, disarming the pin).
**Bugs found by the emulator round (both would have passed the unit
suite).** (1) `GlyphLayout.ByteOffsets` are window-relative, not absolute:
capturing the pin's byte without adding the window start made the pin
chase a moving offset and never converge. (2) The scale change left the
old `GlyphLayout` in place; the next frame computed its window start with
the OLD line height and the NEW rescaled offset — a window ~10k lines
from the viewport (visible as a ~1000-line jump). Fixed by
`invalidateShapedLayout()` on every scale change.
**Bug found by the on-device round (the emulator round could never have
found it — adb has no two-finger input, and the debug `pinch` command
bypasses the probe entirely).** On the phone a real two-finger pinch did
nothing. On-device logcat (probe event logs + per-frame event counts)
showed the scroll gesture receiving every finger move while both probe
tags received zero events. Root cause: the probes were declared as
`struct{}` fields of the Renderer. The unnamed fieldless `struct{}` is a
SINGLE canonical Go type, so `pressProbe`, `pinchProbe` (and a
diagnostic third) were the SAME tag value. Gio's router keys handlers by
tag value, so all three `event.Op` registrations collapsed into one
handler; the press probe's drain (which runs first in `CheckGestures`)
consumed every event for that tag and the pinch probe was structurally
starved. Fixed by giving each probe its own named type
(`pressProbeTag`, `pinchProbeTag`), with a regression test
(`TestProbeTagIdentity`) asserting the tags remain distinct map keys, and
`TestRealDrawOpsProbeHit`, which runs the real `Renderer.Draw` op stream
through a real `input.Router` and asserts the probe tags receive the
pointer press. Verified on the Pixel 9 Pro: 927 probe events across a
multi-pinch session, 137 per-frame factors emitted and applied (net
scale 1.70×, font visibly enlarged), scroll suppressed mid-pinch,
anchor held.
## 19. Pinch tracker: explicit pair, two-mover formation, slow frames (2026-08-23)
The §18 probe design ("two lowest-ID pointers") was replaced by an explicit
pair state machine (`internal/ui/pinch_tracker.go`, pure and unit-tested;
the renderer's `consumePinchProbe` is now a thin adapter that feeds events,
executes the tracker's grabs, and emits its factor). Four on-device failure
modes drove the rewrite, plus a whole class of slow-frame bugs only visible
on the ~1 fps emulator:
1. **Single-finger scroll changed the font** — the pair was re-derived from
whatever pointers happened to be present, so a scroll finger got paired
with a stale pointer and its drags became "pinch".
2. **Scroll-down enlarged the font** — same root: the scroll finger's
distance to a stale second pointer grows as it moves.
3. **Two fingers produced a sudden zoom before the pinch** — the baseline
(`prevDist`) survived from the previous pinch, so a new pinch 2.5× wider
emitted 2.5× on its first frame.
4. **Pinch-out stopped and became a scroll** — the pair was not explicit;
once a finger moved past the scroll slop the router handed the pointer
to the scroll gesture and the "pair" silently switched composition.
**Explicit, stable pair + grabs.** When the pair forms, the adapter issues
`pointer.GrabCmd` for BOTH fingers (exclusive delivery to the probe:
releases arrive even off-clip, and scroll/click are dropped with a Cancel —
so the pair can never be stolen mid-gesture, failure mode 4). The pair's
composition and its baseline are never re-derived from ambient pointers.
`factor()` = current pair distance / previous frame's distance, one factor
per frame (the Android driver replays historical samples — several drags per
frame — so the font sees one factor per frame, not per sample). A sanity
clamp drops factors outside 0.110 and advances the baseline, so a
teleporting pointer (ID-reuse noise) cannot jump the font. When a pair
finger lifts, the other becomes the **survivor**: it stays grabbed (Gio
v0.10 has no release-grab) and its drags are forwarded as a plain scroll
delta (`survivorScroll`), so the finger is not dead. A second finger that
returns re-forms the pair with a fresh baseline.
**Formation requires TWO MOVING fingers — and nothing else.** The dominant
real-world case is a palm edge already down when the two pinch fingers
land; a static rule about "which finger is the palm" (the oldest? the
newest? the still one?) cannot survive both palm-first and palm-last hand
lands. Movement is the only signal that works for both: while 23 fresh
fingers are down the tracker is *pending*; the pair forms — at `factor()`
time, after the whole frame's events, never per-event (per-event locking
in the first mover pair seen would pair a finger with a drifting palm) —
when two pending fingers have each moved more than `pinchMoveEps` (10 px)
from where they pressed, and it is the mover pair whose **distance**
changed most (a drifting palm's distance to a finger changes little; a
pinch's does). A lone mover is a scroll, never a pair; a unison movement
(a two-finger slide) leaves the distance unchanged and forms nothing.
Consequences verified: a resting (pruned, >300 ms) or still palm can never
enter the distance; the three fresh-fingers case pairs the pinch fingers;
the re-form candidate (a finger landing on a survivor) must also move
before it becomes the pair.
**Baseline = the PRESS distance.** Any spread that happened before the pair
starts is owed, not lost: the formation frame emits `d_current /
d_press`, and a pinch that breaks before its first `factor()` settles the
same owed factor at the break.
**Slow frames (the ~1 fps emulator batches a whole gesture into one
drain).** (a) *Born-and-dead in one frame*: presses, drags and BOTH
releases in one drain — pending releases are held **lazy** (`released`
map) until `factor()`: the pair forms at the fingers' final positions,
then breaks there (no survivor when both released), settling the owed
factor. (b) *Pair broke mid-frame*: `brokeFactor`/`brokeMid` are settled
at the break (against `prevDist`, or the press distance when fresh) and
emitted by the subsequent `factor()` call, which would otherwise see
`on==false` and drop the frame's movement.
**Emulator multi-touch injection.** adb has no two-finger input, so the
failure modes could not be tested end-to-end until `tools/touchinject`: a
platform-signed (AOSP test key, `INJECT_EVENTS` granted) toy app whose
broadcast receiver injects a scripted `MotionEvent` stream
(`down/move/up/wait`, display px) through
`InputManager.injectInputEvent``/dev/uhid` is a dead end (no kernel
module in the image). Two known flakes, both documented in the harness:
the receiver process is "cached" and the 1.5 GB emulator OOM-kills it
mid-script occasionally (the harness verifies `=== done` in logcat and
re-runs; service routing is blocked by Android 12+ background-start
restrictions, and the AVD's locked bootloader blocks the system-app
escalation); and burst drags (all moves within one frame's drain) do not
scroll — the app is on-demand-rendering at ~1 fps, so scroll tests space
the moves ~80 ms apart, which also matches what a real finger produces
over several frames.
**End-to-end results (real injected MotionEvents, big wrapped file).**
Single-finger drag: zero factors, scale unchanged (font), content scrolls.
Two-finger pinch-out 300→596 px: exactly one factor 596/300 = 1.98667,
font ~2×. Palm-first three fingers (palm resting and still, pinch fingers
landing 80/120 ms later): pair is the two pinch fingers — the factor
tracks the pinch, the palm never enters the distance. Lift one finger
mid-pinch: the factor stream stops at the lift (font frozen), the
survivor's 600 px drag scrolls the content 600/3.5 = 171.4 dp. The
one-frame leak at formation (the pair's own drags of the formation frame
still reach scroll, since the grabs commit next frame) is bounded by the
scroll slop — the deliberate cost of not grabbing on press, which would
kill single-finger scrolls.
**Testing.** `pinch_test.go` now covers: factor-series telescoping
(baseline = press distance), single-finger never scales, resting/stale
palm excluded, extra finger during an active pinch ignored, fresh baseline
per pinch, sanity clamp, the three slow-frame shapes (full pinch in one
frame, stationary born-dead, born-and-dead), palm-first three fingers,
and survivor scroll + re-form (candidate must move).
`real_draw_probe_test.go` runs the real `Renderer.Draw` op stream through
a real `input.Router`: press frame (pending, nothing), formation frame
(grabs + owed factor, one-frame scroll leak), post-formation frames (scroll
sees nothing of the pair), off-clip survival, release via the grab,
survivor scroll forwarding, re-form.
## 20. IME open: content must not shift (2026-08-23)
**Bug.** With the soft keyboard open (adjustResize), the editor content
jumped up by exactly 32 dp (112 px) every time the keyboard appeared.
Top-anchored layout keeps the window start line put when only the
viewport height changes, so the shift was not our layout: KBW
instrumentation of every `ScrollOffset` writer showed
`HandleScroll` receiving a single +112 px delta at the resize frame.
**Root cause (Gio, not the app).** `gioui.org/app` `window.go`, on every
frame whose viewport *shrank*, calls `Router.RevealFocus(viewport)`
"scroll the focused widget into view". For a text editor the focused
field's registered bounds (stale — from the pre-resize, taller frame)
extend below the new viewport, so RevealFocus synthesizes a
`pointer.Scroll` event (`Source: Touch`, position (0,0), Y = the nudge)
delivered to the focused field's scroll handler. `gesture.Scroll`
consumes it like any wheel scroll → `HandleScroll` → the 32 dp jump.
The event is invisible to the app: it never enters the pointer queue
(no `MotionEvent` on the Android side), it is manufactured by the router
during `processEvent(frameEvent)`, before the app's frame handler runs.
Reproduced at the router level: `RevealFocus` on a shrunken viewport
queues exactly one scroll event for the gesture's tag.
**Why not the obvious fixes.**
- Zeroing the scroll *range* on the shrink frame does nothing: the router
UNIONs scroll ranges into the handler's filter across frames
(`pointerFilter.Add`/`Merge`), so the historical max can never shrink
back to zero — the clamp stays at ±∞ forever.
- Patching `app/window.go` to drop the shrink→RevealFocus call would mean
shipping a forked gioui (the build constraint is clean v0.10.0).
- `adjustNothing` removes the resize but hides the cursor line under the
keyboard.
**Fix (app-side, two files).**
- `cmd/pad/main.go`: on each `FrameEvent`, detect a shrink
(`e.Size` smaller than the previous frame's) and set
`renderer.ZeroWheelScroll` for that one frame.
- `internal/ui/render.go` (`CheckGestures`): when flagged, drain
`pointer.Scroll` events for the editor scroll gesture's tag
(`q.Event(pointer.Filter{Target: reg.scroll, Kinds: pointer.Scroll})`)
before `gesture.Scroll.Update` consumes anything. Only the synthesized
nudge matches: finger scroll is `pointer.Drag`, inertia is the
flinger, and on a phone there is no trackpad wheel. Normal frames are
untouched.
**Verification.**
- `reveal_focus_drain_test.go`: real `input.Router` + real
`Renderer.Draw` ops; the focused field (KeyDown interaction required —
it records the `event.Op` tag reference, and a per-frame
`key.FocusFilter` consumer marks the handler focusable, else the key
queue clears the focus each frame), shrunken viewport, `RevealFocus`
exactly one synthetic scroll queued for the gesture tag; the drain
terminates and `gesture.Scroll.Update` then returns 0.
- Emulator E2E (real injected taps, keyboard really opens, window
2560→1527 px): pre-fix the scroll gesture emitted delta=112 on the
shrink frame and a screenshot cross-correlation showed a 112 px content
shift; post-fix the drain consumes the event, the gesture delta is 0,
and the cross-correlation shift is 0 (corr 0.987). The perf-CSV
(ScrollDP per logic frame) shows no 32 dp step when the keyboard opens
on the final build.
**Notes.** The `aosp_atd` emulator later started ANR-ing Pad on first
frame — the ANR trace shows the main thread in
`GioView.onFrameCallback``glDeleteBuffers` → gfxstream guest →
`madvise` (91 s system time): the emulated GPU's buffer-free path,
unrelated to input handling. Final verification therefore used a small
file (fast first frame) plus the router-level test.

74
doc/release.md Normal file
View File

@ -0,0 +1,74 @@
# Release process
A release is **`./scripts/release.sh`** — that one command is the
executable form of this document. If this document and the script ever
disagree, fix both in the same change.
## What a release does
1. **Gate 1 — static checks**: `scripts/check.sh` (go vet + staticcheck).
2. **Gate 2 — test suite**: `go test -count=1 ./...` — includes
`TestNoFramesWhileIdle` (internal/test/e2e), the headless
frame-regression guard.
3. **Gate 3 — frame-regression profile, EMULATOR only**:
`scripts/profile_emulator.sh` (auto-selects an emulator; it never
auto-selects a phone). See architecture.md §11 for what it measures and
why the budgets are shaped the way they are.
4. **Build**: `scripts/build_phone.sh --no-install` (arm64+arm APK →
`cmd/pad/pad-phone.apk`; static checks are skipped here, already gate 1).
5. **Install to every connected device** — emulators **and phones**.
Any gate failure aborts before the install. The working tree may be dirty;
the script warns, but the gates then certify the *current* (possibly
uncommitted) state — commit the release work before shipping.
## Install policy
- Pushing a release build to the developer's phone is the **default and
expected** behavior — `release.sh` installs to all `adb devices` entries,
which is what makes the phone just work without knowing its serial
(the wireless-debugging `IP:port` changes on every reconnect; the
install loop sidesteps that entirely).
- On a phone that has never had the app, grant **"All files access"**
(MANAGE_EXTERNAL_STORAGE) in system settings after the first install.
## On-device profiling/testing: NOT part of a release
The release touches a phone **only to install the APK**. It never
profiles, tests, force-stops, or otherwise drives a phone:
- On-device runs (e.g. `scripts/profile_emulator.sh -s <phone serial>`)
are **diagnostics**: they force-stop the app, seed and remove a file in
its storage, and read its profiler output. The developer may be using the
phone while a release runs.
- Therefore an on-device run requires **explicit approval in the moment**
(ask first, name the serial), and its results are informational, never a
gate. The frame-regression *gate* is tier 2 (headless) + tier 3
(emulator) only.
## Failure handling
| Gate | Fails on | First things to check |
|---|---|---|
| 1 static | vet/staticcheck findings | The findings; don't waive without a reason in the commit message |
| 2 tests | any test | `go test -count=1 -run <Name> ./...` to isolate; e2e tests are deterministic — a flake is a bug in the test |
| 3 profile | a phase over its frame budget | The printed phases + `PERF`/`PERF-PRESENT` logcat lines (a spinner is usually obvious); confirm the emulator wasn't under host load (rerun once before investigating) |
| build | gogio/apktool/sign | Toolchain notes in `scripts/build_phone.sh` header |
If the profile gate is in question, a diagnostic run **on the phone**
(requires approval) is the escalation path — not a replacement for the gate.
## Versioning (known gap)
The APK is currently built with the gogio-default version
(`1.0.0.1`); releases do **not** bump a version yet. When release
distribution starts mattering, add a version step here (and to
`release.sh`) — until then, "which release" is identified by the git
commit the APK was built from.
## Emulator requirements
Gate 3 needs a running emulator (`adb devices` shows `emulator-*`).
`scripts/emu.sh` manages the AVD (see its header); the AVD must have had
"All files access" granted once (it has). No phone needs to be connected
for a release to succeed — the install step simply reports no devices.

View File

@ -60,6 +60,34 @@ 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).
- **Soft keyboard does not shift content:** opening or closing the IME
resizes the window (adjustResize); the editor is top-anchored, so the
visible text stays put. Gio's window otherwise synthesizes a scroll-to-
focus nudge on any frame the viewport shrinks (RevealFocus, aimed at the
focused field's stale pre-resize bounds), which would shift the content
up; the app drains that one synthetic scroll on the shrink frame
(development_plan.md §20).
- **Pinch to change font size:** a two-finger pinch inside the editor text
changes the app's font size continuously — the rendered size tracks the
inter-finger distance with no snapping to whole points (the scale is a
float32 multiplied by the per-frame distance ratio, clamped to 0.5×3.0×
of the 14sp base). It is an *app-local* scale layered on top of the
system user font setting. The CONTENT UNDER THE PINCH CENTER STAYS
FIXED: the logic captures the **content point** under the finger
midpoint — the glyph byte and the point's offset from that glyph's
baseline — and re-derives the scroll offset to keep that point on the
center. A content point, not a layout point: when the font change
re-wraps a line, a *visual* line's text moves (fragment 2 of the new
wrap is different text), so pinning (line, fragment) would leave a
different character at the center. The pin therefore names the
character itself (byte + baseline offset) and re-anchors it under every
newly shaped layout — the re-wrap included — which is what keeps the
character under the fingers through the rewrap. (A (line, fragment,
sub-line) anchor stands in as fallback for points off any glyph.)
Single-finger scroll is suppressed mid-pinch (the pinch owns the two
fingers) and takes over the viewport on the first scroll after the
pinch. The scale is part of the relaunch session (§2.4) and survives
restarts.
- **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
@ -95,7 +123,8 @@ elsewhere.
the newer content when it completes ("latest state wins"). Writes stage to
a unique per-call temp file and rename into place, so a crash or a
concurrent reader never observes a partial file. Failed writes are retried.
This is the only persistence mechanism.
This is the only document persistence mechanism (the app's own state is a
separate tiny session file — §2.4).
### 2.3 Large files (measured)
@ -110,6 +139,43 @@ elsewhere.
in-range files); edits splice only affected chunks. Details:
`architecture.md` §6.
### 2.4 State restoration on relaunch
- On launch, Pad re-opens the file from the last session and lands straight
on the editor page, restoring the cursor, scroll position, live selection,
and the find bar (query, open/closed, current match). A relaunch therefore
never requires re-browsing to the last file.
- The scroll position is persisted as the LOGICAL line at the viewport top
plus the sub-line remainder (the raw pixel offset is kept too, for
pre-line-coordinate session files). The pixel offset alone is not
restorable: it lives in visual-line space, and the wrap counts that map
it to a line are rebuilt from estimates on relaunch — lines above the
restored viewport are never shaped, so the offset would land a line
deeper by every wrapped continuation above it. The inverse drift also
happens in the first frames after relaunch: the pre-restore (top-of-file)
window is what gets shaped first, and its real wrap counts landing below
the restored line would drag the line-derived offset to a shallower line.
The restore therefore pins the logical line: while the restore settles
(until the restored window itself has shaped, a 2 s timeout, or the user
scrolls / a search jumps), every wrap-count correction re-derives the
offset as `V(line)·lh + sub` under the current index, so the viewport
stays on the restored line regardless of which counts have landed.
- The snapshot is a tiny JSON file (a few hundred bytes) written by the
cmd layer: `$HOME/.pad/session.json` off-Android, and
`/storage/emulated/0/Pad/session.json` on Android (the dir that already
carries the browser's `.pad` index caches). Search results are NOT stored:
they are regenerated by re-scanning the restored query, and the current
match is re-selected by byte offset.
- The snapshot is written rate-limited (250 ms, only on change) while the
app runs; a file change or a selection appearing/vanishing saves
immediately; on Android the activity's onStop (recents-wipe, app switch)
flushes it one last time; and it is written unconditionally at shutdown.
A kill shortly after a change therefore loses at most a fraction of a
second of state.
- If the restored file no longer exists (deleted/moved, e.g. by Syncthing),
the app falls back to the browser page; positions that exceed a shrunk
file are clamped to its new end.
## 3. Code organization (actual)
```
@ -119,7 +185,8 @@ internal/
browser/ # BrowserState, BrowserManager, sort, search,
# pagination, layout, handlers
editor/ # Logic goroutine, State, ChunkedBuffer, LineIndex,
# IME handling, autosave, Frame handoff (frame.go)
# IME handling, autosave, relaunch session (session.go),
# Frame handoff (frame.go)
io/pool/ # Worker pool (priority lanes), task types,
# real/ — real filesystem (rooted at /)
# mock/ — in-memory FS for tests
@ -153,9 +220,12 @@ Full details, channel topology, and ownership rules: [`architecture.md`](./archi
inflates memory (this caused a 1 GB leak, fixed in Phase 3).
4. **The IME snippet is the visible window**, and rune↔byte conversion happens
in one place (`HandleReplaceRange` / `RuneIndexToByte`).
5. **Autosave is the only persistence.** If state persistence (last file,
cursor, scroll) is added later, it must go through the same
owner-dispatches-a-task pattern.
5. **Persistence goes through the owner.** Autosave dispatches `WriteFile`
tasks for document content; the relaunch session (last file, cursor,
scroll, selection, find state) is marshaled by the owner and written by
the cmd layer's saver callback (tiny JSON, synchronous, torn writes
rejected on load). If session state ever grows beyond a few KB, move the
write to a worker-pool task (autosave pattern).
6. **Logic work stays < 16 ms.** Anything that can block or scan more than the
viewport goes to the worker pool.
7. **Scroll offset is always clamped to `[0, maxScroll]`,** where
@ -183,7 +253,6 @@ recorded here so future rounds don't mistake doc text for behavior:
| Feature | Status | Notes |
|---|---|---|
| Undo (any) | **not implemented** | No undo stack exists; the old `SaveUndoTask` is dead code. |
| State restoration on relaunch | **not implemented** | Last file / cursor / scroll are not persisted. |
| External change detection | **not implemented** | No mtime compare on open/resume, no watcher, no Keep/Reload prompt. |
| Syncthing conflict handling | **not implemented** | No `.sync-conflict-*` file detection or merging. |
| File-system watcher | **not implemented** | Browser does not live-refresh; it re-scans on navigation. |

View File

@ -66,7 +66,7 @@ func computeVisiblePageRange(s *BrowserState) (minPage, maxPage int) {
if minPage < 0 {
minPage = 0
}
maxPage = (s.GetScrollIndex() + s.VisibleCount)/PageSize + PrefetchDist
maxPage = (s.GetScrollIndex()+s.VisibleCount)/PageSize + PrefetchDist
return minPage, maxPage
}
@ -164,4 +164,3 @@ func filterEntriesByQuery(entries []Entry, query string) []int {
return results
}

View File

@ -28,7 +28,7 @@ func TestLoadPage_FromIndexAtOffset(t *testing.T) {
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: allEntries,
Entries: allEntries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(allEntries, SortModeNameAsc),
"date_desc": buildPositionMap(allEntries, SortModeDateDesc),
@ -76,7 +76,7 @@ func TestLoadPage_LastPagePartialFromIndex(t *testing.T) {
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: allEntries,
Entries: allEntries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(allEntries, SortModeNameAsc),
"date_desc": buildPositionMap(allEntries, SortModeDateDesc),
@ -116,7 +116,7 @@ func TestLoadPage_OutOfBoundsFromIndex(t *testing.T) {
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: allEntries,
Entries: allEntries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(allEntries, SortModeNameAsc),
"date_desc": buildPositionMap(allEntries, SortModeDateDesc),
@ -195,7 +195,7 @@ func TestGetEntryByIndex(t *testing.T) {
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: allEntries,
Entries: allEntries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(allEntries, SortModeNameAsc),
"date_desc": buildPositionMap(allEntries, SortModeDateDesc),
@ -236,7 +236,7 @@ func TestGetEntryByIndex_UnloadedPage(t *testing.T) {
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: allEntries,
Entries: allEntries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(allEntries, SortModeNameAsc),
"date_desc": buildPositionMap(allEntries, SortModeDateDesc),

View File

@ -13,11 +13,11 @@ import (
// DirectoryIndex holds a cached index of a directory's contents.
// Per design: index stores raw metadata; sorting is pre-computed via position maps.
type DirectoryIndex struct {
Path string `json:"path"`
Mtime time.Time `json:"mtime"`
EntryCount int `json:"entry_count"`
Entries []Entry `json:"entries"`
SortOrders map[string][]int `json:"sort_orders,omitempty"` // key: "name_asc", "name_desc", etc.
Path string `json:"path"`
Mtime time.Time `json:"mtime"`
EntryCount int `json:"entry_count"`
Entries []Entry `json:"entries"`
SortOrders map[string][]int `json:"sort_orders,omitempty"` // key: "name_asc", "name_desc", etc.
}
// getCachePath returns the path to the cached index file for a directory.

View File

@ -224,5 +224,3 @@ func TestBuildIndex_LargeDirectory(t *testing.T) {
t.Errorf("buildIndex took %v, expected < 5s", elapsed)
}
}

View File

@ -160,8 +160,6 @@ func recomputeSearchResults(s *BrowserState) {
}
}
// ComputeVisibleEntriesForTest is exported for testing purposes.
// It builds the list of ui.ListItem entries that should be rendered.
func ComputeVisibleEntriesForTest(state *BrowserState) []ui.ListItem {
@ -224,7 +222,7 @@ func computeSearchResults(state *BrowserState) []ui.ListItem {
var items []ui.ListItem
for idx := startIndex; idx < endIndex; idx++ {
rawIdx := state.SearchResults[idx]
entry, ok := getEntryByIndex(state, rawIdx)
if !ok {
// Page not loaded yet; add placeholder

View File

@ -32,7 +32,7 @@ func makeTestState(t *testing.T, entryCount int, scrollIndex int, visibleCount i
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),

View File

@ -67,7 +67,7 @@ func TestLazyLoadingLargeDirectory(t *testing.T) {
// 3. Scroll down to page 50
state.ScrollOffset = 50 * 100 * state.EntryHeight // Scroll to middle
bm.OnScroll()
// Wait for load pages result for scroll
res, ok = getResult(15 * time.Second)
if !ok {

View File

@ -145,7 +145,7 @@ func TestPixelScroll_EvictionTriggered(t *testing.T) {
entries[i] = NewEntry("/test/file.txt", "file.txt", 100, s.TapTimestamp, false)
}
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),
@ -184,7 +184,7 @@ func TestPixelScroll_PrefetchTriggered(t *testing.T) {
entries[i] = NewEntry("/test/file.txt", "file.txt", 100, s.TapTimestamp, false)
}
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),

View File

@ -83,7 +83,7 @@ func TestScroll_PrefetchTriggered(t *testing.T) {
}
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),
@ -131,7 +131,7 @@ func TestScroll_EvictionTriggered(t *testing.T) {
}
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),
@ -246,7 +246,7 @@ func TestScroll_PrefetchTriggeredOnPageBoundary(t *testing.T) {
}
// Build position maps for sort modes
s.SortIndex = &DirectoryIndex{
Entries: entries,
Entries: entries,
SortOrders: map[string][]int{
"name_asc": buildPositionMap(entries, SortModeNameAsc),
"date_desc": buildPositionMap(entries, SortModeDateDesc),

View File

@ -171,7 +171,7 @@ func TestSearch_JumpToFirst(t *testing.T) {
if len(s.SearchResults) == 0 {
t.Fatalf("expected results for 'file_3', got 0")
}
// The first result should be index 3 (file_3).
// With 12 results, it fits in 20 visible. ScrollIndex should be 0.
if s.GetScrollIndex() != 0 {

View File

@ -69,4 +69,3 @@ func comparator(mode SortMode) func(a, b Entry) int {
return func(a, b Entry) int { return 0 }
}
}

View File

@ -19,20 +19,20 @@ type Entry struct {
// Page represents a chunk of directory entries loaded from disk.
type Page struct {
Index int // Page number (0-based)
Entries []Entry // Entries in this page
Loaded bool // True if page data is in memory
Dirty bool // True if page needs refresh (external change detected)
Index int // Page number (0-based)
Entries []Entry // Entries in this page
Loaded bool // True if page data is in memory
Dirty bool // True if page needs refresh (external change detected)
}
// BrowserState holds all mutable browser state owned by the logic goroutine.
type BrowserState struct {
// Navigation
CurrentPath string // Currently browsed directory (relative to root)
History []string // Path history for navigation
ScrollOffset float64 // Vertical scroll offset in pixels (per-pixel scrolling)
EntryHeight float64 // Height of a single entry in pixels
VisibleCount int // Number of entries currently visible
CurrentPath string // Currently browsed directory (relative to root)
History []string // Path history for navigation
ScrollOffset float64 // Vertical scroll offset in pixels (per-pixel scrolling)
EntryHeight float64 // Height of a single entry in pixels
VisibleCount int // Number of entries currently visible
// Lazy loading
Pages map[int]*Page // Loaded pages by page index
@ -44,8 +44,8 @@ type BrowserState struct {
SortMode SortMode // Current sort mode
// Search
Query string // Current search query (forwarded from the main-owned search widget)
SearchResults []int // Indices of matching entries (empty = no filter)
Query string // Current search query (forwarded from the main-owned search widget)
SearchResults []int // Indices of matching entries (empty = no filter)
// NOTE: the search bar's widget.Editor is intentionally NOT part of this
// state: Gio mutates widget state on the main goroutine during draw, and
// this state is owned by the logic goroutine (architecture.md §1). The
@ -62,8 +62,8 @@ type BrowserState struct {
}
const (
PageSize = 100 // Entries per page (tunable; ~5KB per page in memory)
PrefetchDist = 2 // Pages to prefetch beyond visible region
PageSize = 100 // Entries per page (tunable; ~5KB per page in memory)
PrefetchDist = 2 // Pages to prefetch beyond visible region
)
// NewEntry creates a new Entry from the given parameters.
@ -127,10 +127,10 @@ func formatSize(bytes int64) string {
// EvictPages removes pages that are far from the current scroll position.
func (s *BrowserState) EvictPages() {
minPage := s.GetScrollIndex()/PageSize - PrefetchDist
maxPage := (s.GetScrollIndex() + s.VisibleCount)/PageSize + PrefetchDist
maxPage := (s.GetScrollIndex()+s.VisibleCount)/PageSize + PrefetchDist
for idx, page := range s.Pages {
if idx < minPage || idx > maxPage {
page.Entries = nil // Release memory
page.Entries = nil // Release memory
page.Loaded = false
}
}

View File

@ -103,7 +103,7 @@ func TestFormatSize(t *testing.T) {
{0, "0 B"},
{500, "500 B"},
{1024, "1.0 KB"},
{1024*1024, "1.0 MB"},
{1024 * 1024, "1.0 MB"},
{1024 * 1024 * 1024, "1.0 GB"},
}
@ -118,7 +118,7 @@ func TestFormatSize(t *testing.T) {
func TestBrowserStateEvictPages(t *testing.T) {
s := NewBrowserState()
s.EntryHeight = 48.0
s.ScrollOffset = 500 * 48 // Page 5
s.ScrollOffset = 500 * 48 // Page 5
s.VisibleCount = 10
// With ScrollIndex=500, PageSize=100:
@ -127,10 +127,10 @@ func TestBrowserStateEvictPages(t *testing.T) {
// Pages 3-7 should be kept, others evicted
// Add pages at various positions
s.Pages[0] = NewPage(0, nil) // Should be evicted (far)
s.Pages[3] = NewPage(3, nil) // Should be kept (at min boundary)
s.Pages[5] = NewPage(5, nil) // Should be kept (visible)
s.Pages[7] = NewPage(7, nil) // Should be kept (at max boundary)
s.Pages[0] = NewPage(0, nil) // Should be evicted (far)
s.Pages[3] = NewPage(3, nil) // Should be kept (at min boundary)
s.Pages[5] = NewPage(5, nil) // Should be kept (visible)
s.Pages[7] = NewPage(7, nil) // Should be kept (at max boundary)
s.Pages[10] = NewPage(10, nil) // Should be evicted (far)
s.EvictPages()

View File

@ -14,10 +14,10 @@ func TestWriteFailureTracking(t *testing.T) {
state := NewState()
filename := "test.txt"
state.Editor.Filename = filename
// Simulate a write failure
state.Editor.SetWriteFailed(filename, true)
if !state.Editor.WriteFailed() {
t.Errorf("Expected WriteFailed() to be true")
}
@ -34,7 +34,7 @@ func TestWriteFailureTracking(t *testing.T) {
func TestAutoSave_RetryFails(t *testing.T) {
// Setup: New Logic, set mock FS to fail
l := NewLogic(nil, "/", func(string) {})
// Start a goroutine to drain frameChan to prevent deadlocks
go func() {
for range l.frameChan {
@ -50,16 +50,16 @@ func TestAutoSave_RetryFails(t *testing.T) {
if !ok {
t.Fatal("mockFS is not a *mock.FileSystem")
}
mfs.SetWriteError(true)
mfs.SetWriteError(true)
// 1. Manually dispatch a write task
task := pool.NewWriteFileTask(filename, []byte("hello"), l.mockFS)
l.workerPool.Dispatch(task)
// 2. Manually process the result to trigger the failure state
res := <-l.workerPool.ResultChan()
l.handleWorkerResult(res)
// 3. Assert failure
if !l.state.Editor.WriteFailed() {
t.Errorf("Expected WriteFailed() to be true")

View File

@ -25,7 +25,7 @@ func TestBackspace(t *testing.T) {
state := NewState()
TheState = state
state.Editor.Buffer = "Hello"
state.Editor.CursorPosition = 5
state.Editor.CursorPosition = 5
HandleBackspace() // Assuming we create this function

View File

@ -189,9 +189,9 @@ func TestLargeFileChunkBoundary(t *testing.T) {
// (The old fixed-slot model treated each chunk as an independent buffer and
// did NOT shift later chunks; that was the bug this design replaces.)
expectedStr := string(initialContent)
expectedStr = expectedStr[:65000] + "CHUNK1" + expectedStr[65000:] // insert 1 @65000
expectedStr = expectedStr[:65535] + "BOUNDARY" + expectedStr[65535:] // insert 2 @65535
expectedStr = expectedStr[:262000] + "CHUNK3" + expectedStr[262000:] // insert 3 @262000
expectedStr = expectedStr[:65000] + "CHUNK1" + expectedStr[65000:] // insert 1 @65000
expectedStr = expectedStr[:65535] + "BOUNDARY" + expectedStr[65535:] // insert 2 @65535
expectedStr = expectedStr[:262000] + "CHUNK3" + expectedStr[262000:] // insert 3 @262000
expected := []byte(expectedStr)
if len(savedContent) != len(expected) {

View File

@ -1,26 +1,26 @@
package editor
import (
"testing"
"pad/internal/ui"
"testing"
)
func TestEditorLayout_Filename(t *testing.T) {
// Setup: Reset state
TheState = NewState()
// Set a filename
expectedFilename := "test.txt"
TheState.Editor.Filename = expectedFilename
// Run layout
screenWidth := ui.Dp(400)
screenHeight := ui.Dp(800)
elements := EditorLayout(screenWidth, screenHeight, false)
// Find top bar (assuming it's the first container element)
topBar := elements[0].(ui.Container)
// Find the filename label in the top bar (the back icon precedes it).
var filenameLabel ui.Label
for _, c := range topBar.Children {
@ -37,18 +37,18 @@ func TestEditorLayout_Filename(t *testing.T) {
func TestEditorLayout_DefaultFilename(t *testing.T) {
// Setup: Reset state
TheState = NewState()
// Filename is empty
TheState.Editor.Filename = ""
// Run layout
screenWidth := ui.Dp(400)
screenHeight := ui.Dp(800)
elements := EditorLayout(screenWidth, screenHeight, false)
// Find top bar (assuming it's the first container element)
topBar := elements[0].(ui.Container)
// Find the filename label in the top bar (the back icon precedes it).
var filenameLabel ui.Label
for _, c := range topBar.Children {

View File

@ -23,9 +23,13 @@ import (
// with; the main goroutine compares it against the (main-owned) search
// widget's text and forwards changes via SearchQueryChan.
type Frame struct {
Elems []ui.Element
Scale float32
FontScale float32 // user font-size setting the logic bookkeeping used
Elems []ui.Element
Scale float32
FontScale float32 // user font-size setting the logic bookkeeping used
// AppFontScale is the app-local pinch font scale (1.0 = default, 0 =
// not set yet). The renderer multiplies the editor font size by it;
// FontScale is already folded into gtx.Metric (PxPerSp).
AppFontScale float32
FocusedElementID string
Query string
// FindQuery: the in-file search query the logic goroutine has processed
@ -36,6 +40,10 @@ type Frame struct {
// FindClearSeq mirrors EditorState.Find.ClearSeq: main wipes the widget
// input once per NEW value (the X button cleared the logic-side query).
FindClearSeq int
// ScrollOffset is the editor scroll offset this frame's elements laid
// out at, shipped with the shaped glyph layout (LayoutFeedback) so the
// logic can express layout positions in content coordinates.
ScrollOffset ui.Dp
// WindowStartByte / WindowStartLine / EditSeq: the editor window this
// frame's elements describe. The main goroutine forwards them with the
// shaped glyph layout (LayoutFeedback) so the logic goroutine can apply
@ -45,23 +53,33 @@ type Frame struct {
WindowStartLine int // -1 when the frame has no editor window
WindowText string // the editor window this frame's text element holds
EditSeq uint64
// ViewportDegenerate is set when this frame was built before the
// window's pixel size was known (0x0). Its editor window, if shaped at
// all, was shaped at zero width: the shaper wraps every line into many
// visual lines, and feeding those counts back (LayoutFeedback) would
// poison the WrapIndex for the window's lines. The main goroutine
// drops the feedback for such frames.
ViewportDegenerate bool
}
// frameOf wraps a computed element tree with the current view-state
// snapshot. Must be called on the logic goroutine.
func (l *Logic) frameOf(elems []ui.Element) Frame {
return Frame{
Elems: elems,
Scale: l.state.scale,
FontScale: l.state.fontScale,
FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query,
FindQuery: l.state.Editor.Find.Query,
FindClearSeq: l.state.Editor.Find.ClearSeq,
WindowStartByte: l.state.Editor.IMEWindowStartByte,
WindowStartLine: l.state.WindowStartLine,
WindowText: l.state.Editor.IMEWindowText,
EditSeq: l.state.Editor.EditSeq,
Elems: elems,
Scale: l.state.scale,
FontScale: l.state.fontScale,
AppFontScale: l.state.appFontScale,
FocusedElementID: l.state.FocusedElementID,
Query: l.state.Browser.Query,
FindQuery: l.state.Editor.Find.Query,
FindClearSeq: l.state.Editor.Find.ClearSeq,
ScrollOffset: l.state.ScrollOffset,
WindowStartByte: l.state.Editor.IMEWindowStartByte,
WindowStartLine: l.state.WindowStartLine,
WindowText: l.state.Editor.IMEWindowText,
EditSeq: l.state.Editor.EditSeq,
ViewportDegenerate: l.state.PixelWidth <= 0 || l.state.PixelHeight <= 0,
}
}

View File

@ -40,8 +40,8 @@ func newChunkedState(t *testing.T, content string, chunkSize int) *State {
func TestRuneIndexToByteStr(t *testing.T) {
cases := []struct {
s string
n int
s string
n int
want int
}{
{"", 0, 0},

View File

@ -73,7 +73,11 @@ type Logic struct {
openFileChan chan string
retryChan chan string // auto-save retries
autosaveChan chan struct{} // auto-save debounce ticks (timer -> owner)
inspectChan chan *inspectReq
// flushSession: one-shot request to persist the session snapshot now
// (the OS activity onStop hook, see FlushSession). Buffered 1 so the
// requester never blocks, even if an earlier flush is still queued.
flushSession chan struct{}
inspectChan chan *inspectReq
// Per-file write protocol (see requestSave). Workers are a shared pool and
// the on-disk staging file is per-file, so two concurrent writes for the
@ -91,6 +95,53 @@ type Logic struct {
saveTimer *time.Timer // auto-save debounce timer; non-nil while pending
lastEmit time.Time // time of the last frame emission (profiler cadence)
debugCmdC chan string // one-shot debug commands from the cmd-file poller; nil = disabled
// Relaunch state restoration (spec §7, see session.go): session is the
// snapshot handed in by the cmd layer via BeginRestore (zero = none),
// restoreFile names the open whose stat/read results are the restore's
// ("" = none; any other open cancels it). sessionSaver is the cmd layer's
// file writer; lastSession/lastSessionSave drive the rate-limited
// change-detected save from emitFrame. All touched only on the owner.
session SessionState
restoreFile string
// restoreScroll/restoreScrollArmed hold the snapshot's scroll offset
// until it is safe to apply it: only on an emitFrame whose layout pass
// saw a trustworthy viewport (scale known, size known, restored content
// present — the layout computes MaxScroll and one-way-clamps the offset,
// and on device the first ScaleEvent can precede the size ConfigEvent).
// scaleSeen tracks the first ScaleEvent; restoreContentLanded marks the
// read result that filled the buffer (FileLen alone is set earlier, by
// the stat result, and is not a content-arrival signal).
restoreScroll ui.Dp
restoreScrollArmed bool
// restoreScrollLine/restoreScrollSub/restorePinDeadline implement the
// restore line-pin (see refreshRestorePin in session.go): the
// line-derived scroll offset is only consistent with the all-estimate
// WrapIndex, so while relaunch restore is settling, every wrap-count
// correction landing below the pinned line shifts the offset-to-line
// mapping and would drag the viewport off the restored line. Until the
// restored window itself has shaped (or a timeout, or the user or a
// search takes over), the offset is re-derived from the pinned line
// after each correction.
restoreScrollLine int
restoreScrollSub float64
restorePinDeadline time.Time
// fontPin/fontPinM implement the pinch font-pin (see
// setFontPin/refreshFontPin in session.go): the CONTENT point under the
// pinch center (glyph byte + offset from its baseline, with a
// line/fragment/sub-line fallback) and the center's region-relative Y
// (dp). While armed, every shaped layout re-derives the scroll offset to
// keep that content point under the center as the font scale — and, a
// few frames later, the rewrap — changes.
fontPin contentPin
fontPinM float64
fontPinArmed bool
fontPinDeadline time.Time
scaleSeen bool
restoreContentLanded bool
sessionSaver func(SessionState)
lastSession SessionState
lastSessionSave time.Time
}
// NewLogic creates a new Logic instance, accepting an optional mockFS.
@ -132,6 +183,7 @@ func NewLogic(mfs pool.FileSystem, path string, openfunc func(string)) *Logic {
openFileChan: make(chan string),
retryChan: make(chan string, 1), // Buffered channel
autosaveChan: make(chan struct{}),
flushSession: make(chan struct{}, 1),
inspectChan: make(chan *inspectReq),
writeInFlight: make(map[string]int),
savePending: make(map[string]bool),
@ -211,6 +263,14 @@ func (l *Logic) Run() {
// Dispatch initial directory index build on startup
l.workerPool.Dispatch(pool.NewBuildIndexTask(l.state.Browser.CurrentPath, l.mockFS))
// Relaunch restoration (spec §7): re-open the last file if a session
// was handed in before Run (BeginRestore). The browser index is still
// built: the back button must land on a populated browser.
if l.session.File != "" {
l.restoreFile = l.session.File
l.openFile(l.session.File)
}
for {
select {
case <-l.done:
@ -220,8 +280,16 @@ func (l *Logic) Run() {
// the final flush and promote a stale snapshot.
l.drainWrites()
return
case <-l.flushSession:
// Persist now, bypassing the rate limit: the activity is going
// away (recents-wipe or app switch) and the process may die
// shortly after this returns.
l.flushSessionSave()
case update := <-l.configChan:
update.apply(l.state)
if _, ok := update.(ScaleEvent); ok {
l.scaleSeen = true
}
l.emitFrame()
case fb := <-l.layoutChan:
// Store the full GlyphLayout on editor state.
@ -238,6 +306,24 @@ func (l *Logic) Run() {
// lines since shaping (fb.EditSeq correlates with the content).
if fb.EditSeq == l.state.Editor.EditSeq {
l.state.applyWrapCounts(fb)
// Restore line-pin (see refreshRestorePin): a correction
// landing below the pinned line shifted the offset-to-line
// mapping, so re-derive the offset from the pinned line under
// the corrected index. The restored window's own shaping means
// its neighborhood is real and the pin can stand down.
if l.restoreScrollLine >= 0 {
if fb.WindowStartLine == l.restoreScrollLine {
l.restoreScrollLine = -1
} else {
l.refreshRestorePin()
}
}
// Pinch font-pin (see refreshFontPin): the fresh layout may
// have rewrapped the pinned line; re-derive so the pinned
// content point stays under the pinch center.
if l.fontPinArmed {
l.refreshFontPin(fb)
}
// Search settle (see EditorState.findSettle): the shaping above
// may have corrected the wrap counts around a find-jumped
// viewport; re-scroll while the correction still matters.
@ -266,17 +352,7 @@ func (l *Logic) Run() {
l.state.Editor.findSetQuery(q)
l.emitFrame()
case path := <-l.openFileChan:
// Discard find results for the previous file; the query is kept
// (see EditorState.findReset) and re-scanned against the new file.
TheState.Editor.findReset()
// Create chunked buffer for virtual scrolling
chunkSize := DefaultChunkSize
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
cb.SetWorkerPool(l.workerPool)
TheState.Editor.ChunkedBuffer = cb
// Dispatch stat task to get file size
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
l.openFile(path)
case filename := <-l.retryChan:
log.Printf("Logic: Retrying save for %s", filename)
delete(l.retryScheduled, filename)
@ -304,12 +380,50 @@ func (l *Logic) Run() {
}
}
// openFile starts loading path in the editor (a browser row tap or the
// relaunch restore). Any open of a file that is not the in-flight restore
// cancels the restore: its late stat/read results must not re-apply the
// snapshot's positions to a different file. Must be called on the logic
// goroutine.
func (l *Logic) openFile(path string) {
if l.restoreFile != "" && l.restoreFile != path {
l.abortRestore() // a different open cancels the in-flight one
}
l.releaseFontPin() // the pin names lines of the previous file
// Discard find results for the previous file; the query is kept
// (see EditorState.findReset) and re-scanned against the new file.
TheState.Editor.findReset()
// Create chunked buffer for virtual scrolling
chunkSize := DefaultChunkSize
cb := NewChunkedBuffer(path, chunkSize, l.mockFS, "")
cb.SetWorkerPool(l.workerPool)
TheState.Editor.ChunkedBuffer = cb
// Dispatch stat task to get file size
l.workerPool.Dispatch(pool.NewStatFileTask(path, l.mockFS))
}
// emitFrame computes the current frame, records a profiler probe (if enabled),
// and hands it to the main goroutine. Centralizing emission here ensures the
// in-app profiler (PerfRecord) sees every frame exactly once, on the owner
// goroutine. Must be called on the logic goroutine.
func (l *Logic) emitFrame() {
// Relaunch snapshot (spec §7): persist when the state has changed and
// the rate limit elapsed (tiny JSON file, see session.go).
l.saveSessionIfChanged()
elems := l.state.layout(l.browserManager)
// Relaunch restore (spec §7): land the armed restore scroll AFTER the
// layout pass above (it refreshed MaxScroll for the current viewport and
// one-way-clamps any offset set against an earlier, smaller one). The
// guard defers the application until the viewport is trustworthy — on
// device the first ScaleEvent can precede the size ConfigEvent. The
// re-layout makes this frame carry the restored viewport. The restore is
// complete once the scroll lands; drop restoreFile so a later open of
// this same file is treated as a fresh one.
if l.restoreScrollArmed && l.maybeApplyRestoreScroll() {
l.restoreFile = ""
elems = l.state.layout(l.browserManager)
}
now := time.Now()
if PerfRecord != nil {
var delta time.Duration
@ -396,10 +510,11 @@ func (l *Logic) EnableDebugCmdPoll(dir string) {
}()
}
// applyDebugCmd applies a one-shot debug scroll command from the cmd-file
// poller. Commands: "open <path>" (any page), and, on the editor page,
// "top", "bottom", "frac <0..1>", "dp <int>". Must be called on the logic
// goroutine.
// applyDebugCmd applies a one-shot debug command from the cmd-file poller.
// Commands: "open <path>" (any page), and, on the editor page, "top",
// "bottom", "frac <0..1>", "dp <int>", "pinch <factor>" (relative app font
// scale, as the renderer's pinch probe would deliver) and "fontsize <v>"
// (absolute app font scale). Must be called on the logic goroutine.
func (l *Logic) applyDebugCmd(cmd string) {
s := l.state
fields := strings.Fields(cmd)
@ -419,6 +534,45 @@ func (l *Logic) applyDebugCmd(cmd string) {
l.emitFrame() // OpenFile only mutates state (the tap path emits via its handler)
return
}
// App-local font scale (pinch zoom). Drives the full logic->frame->render
// path the same way a real pinch does (HandleFontPinch is the handler a
// FontPinchEvent carries); adb has no two-finger input, so these
// commands are the on-emulator test hook. `pinch` anchors at the CENTER
// of the editor region (where a real pinch usually starts); `fontsize`
// is an absolute top-anchored set.
if fields[0] == "pinch" || fields[0] == "fontsize" {
if s.page != EditorPage {
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
return
}
if len(fields) < 2 {
log.Printf("DebugCmd: %s needs a value", fields[0])
return
}
f, err := strconv.ParseFloat(fields[1], 32)
if err != nil || f <= 0 {
log.Printf("DebugCmd: bad %s value %q", fields[0], fields[1])
return
}
if fields[0] == "pinch" {
if f > 100 { // a single frame's pinch never spans this much
log.Printf("DebugCmd: pinch factor out of range: %v", f)
return
}
HandleFontPinch(ui.FontPinchEvent{
Scale: float32(f),
Center: ui.Point{
X: s.EditorRegion.X + s.EditorRegion.W/2,
Y: s.EditorRegion.Y + s.EditorRegion.H/2,
},
})
} else {
SetAppFontScale(float32(f))
}
log.Printf("DebugCmd: %q -> appFontScale=%.4f scroll=%d", cmd, s.appFontScale, int(s.ScrollOffset))
l.emitFrame()
return
}
if s.page != EditorPage {
log.Printf("DebugCmd: %q ignored (not on editor page)", cmd)
return
@ -459,6 +613,7 @@ func (l *Logic) applyDebugCmd(cmd string) {
if target > s.MaxScroll {
target = s.MaxScroll
}
l.releaseFontPin() // a debug scroll takes over the viewport
s.ScrollOffset = target
log.Printf("DebugCmd: %q -> scroll=%d maxScroll=%d", cmd, int(s.ScrollOffset), int(s.MaxScroll))
l.emitFrame()
@ -518,7 +673,38 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
// Fallback: populate the deprecated Buffer field
l.state.Editor.Buffer = string(content)
}
// Relaunch restoration (spec §7): the content is in memory, so
// land the snapshot's cursor/selection (clamped to the file)
// and re-scan the restored find query. Path-guarded: a read
// result for a file the user has since replaced must not apply
// the snapshot to the replacement's buffer.
if res.FilePath == l.state.Editor.Filename && l.restoreFile == res.FilePath {
l.applyRestorePositions(len(content))
l.restoreContentLanded = true
// The snapshot has landed: resume session saving. restoreFile
// and the armed scroll stay until the scroll itself lands in
// the emitFrame hook (which needs this content for a real
// MaxScroll), so a different open in between still aborts via
// openFile's restoreFile guard.
// Re-scan the restored query only when the bar was open:
// with the bar closed, the result would be dropped (the apply
// gate requires Visible) and Scanning would stay stuck true;
// findShow re-scans on the next open instead.
if f := &l.state.Editor.Find; f.Visible && f.Query != "" && !f.Scanning {
l.state.Editor.findDispatchScan()
}
// No armed scroll: the snapshot has fully landed with the
// content; lift the save suppression now (the emitFrame
// hook does this when a scroll does land).
if !l.restoreScrollArmed {
l.session = SessionState{}
}
}
}
} else if res.FilePath == l.restoreFile {
// The restored file's content could not be read: drop the
// restore (the editor keeps the empty file view it has).
l.abortRestore()
}
} else if res.TaskType == pool.TypeReadChunk {
// In-range files load fully via SetContent, so this is only a fallback.
@ -539,9 +725,22 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
} else if res.TaskType == pool.TypeStatFile {
if res.Success {
if stat, ok := res.Data.(*pool.FileStat); ok {
// Relaunch restoration (spec §7): the file exists, so land
// the snapshot's find state now (the cursor/selection land
// with the content, the query re-scans against the new file).
if res.FilePath == l.restoreFile && l.restoreFile != "" {
f := &l.state.Editor.Find
f.Query = l.session.FindQuery
f.Visible = l.session.FindVisible
f.RestoreMatch = l.session.FindCurByte
f.Restoring = l.session.FindVisible
}
// Size guard: refuse to edit files above the limit. The browser can
// still list them; the editor shows a "too large to edit" notice.
if stat.Size > MaxEditableFileSize {
if l.restoreFile != "" {
l.abortRestore() // no content load follows; nothing lands
}
l.state.Editor.TooLarge = true
l.state.Editor.TooLargeSize = stat.Size
log.Printf("Logic: %s is %d bytes, exceeds the %d-byte edit limit", stat.Path, stat.Size, MaxEditableFileSize)
@ -555,6 +754,11 @@ func (l *Logic) handleWorkerResult(res pool.Result) {
l.workerPool.Dispatch(pool.NewBuildLineIndexTask(stat.Path, l.mockFS))
}
}
} else if res.FilePath == l.restoreFile && l.restoreFile != "" {
// The restored file is gone (deleted/moved since the last
// session): fall back to the browser instead of showing an
// empty editor.
l.abandonRestore()
}
} else if res.TaskType == pool.TypeBuildLineIndex {
if res.Success {
@ -768,5 +972,11 @@ func (l *Logic) Shutdown() {
l.Done()
l.WaitForExit()
l.FlushAll()
// Final relaunch snapshot (spec §7): after exit the caller owns the
// state single-threaded, like FlushAll above, so one unconditional
// save makes the file exact for the next launch.
if l.sessionSaver != nil {
l.sessionSaver(l.SnapshotSession())
}
l.workerPool.Stop()
}

View File

@ -0,0 +1,386 @@
package editor
import (
"math"
"testing"
"pad/internal/ui"
)
// These tests cover the app-local pinch font scale: EffectiveLineHeight must
// follow the product of the system font scale and the app scale, and the
// pinch handler must apply the relative factor continuously (no rounding to
// whole points) while keeping the viewport top anchored (scroll offset
// rescaled in lockstep with the line height).
func TestEffectiveLineHeight_FollowsAppFontScale(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0 }()
cases := []struct {
sys, app float32
want float64
}{
{0, 1.0, float64(EditorLineHeight())}, // both unknown/default
{1.3, 1.0, float64(EditorLineHeight()) * 1.3},
{1.0, 1.7, float64(EditorLineHeight()) * 1.7},
{1.3, 1.7, float64(EditorLineHeight()) * 1.3 * 1.7},
{1.0, 0.5, float64(EditorLineHeight()) * 0.5},
}
for _, c := range cases {
TheState.fontScale = c.sys
TheState.appFontScale = c.app
got := float64(EffectiveLineHeight())
if math.Abs(got-c.want) > 1e-5 {
t.Errorf("sys=%v app=%v: EffectiveLineHeight=%v want %v", c.sys, c.app, got, c.want)
}
}
}
func TestHandleFontPinch_ContinuousAndAnchored(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
// Start scrolled to a line boundary: 100 lines at the default pitch.
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(100 * lh)
// A sequence of small relative factors: the product is 1.1^5 ~ 1.61051,
// a non-representable float32 — rounding to whole points (or to any
// fixed step) would not land here.
for i := 0; i < 5; i++ {
HandleFontPinch(ui.FontPinchEvent{Scale: 1.1})
}
want := float64(1.1) * 1.1 * 1.1 * 1.1 * 1.1
if math.Abs(float64(TheState.appFontScale)-want) > 1e-6 {
t.Errorf("appFontScale=%v want %v (continuous, no snapping)", TheState.appFontScale, want)
}
// The scroll offset rescaled by the same ratio: the same line (100) at
// the same sub-line fraction (0) stays on top.
wantOff := 100 * lh * want
if got := float64(TheState.ScrollOffset); math.Abs(got-wantOff) > 1e-3 {
t.Errorf("ScrollOffset=%v want %v (anchor preserved)", got, wantOff)
}
// EffectiveLineHeight follows the new scale.
wantLH := lh * want
if got := float64(EffectiveLineHeight()); math.Abs(got-wantLH) > 1e-5 {
t.Errorf("EffectiveLineHeight=%v want %v", got, wantLH)
}
}
func TestHandleFontPinch_Clamps(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
// Zoom out past the minimum.
HandleFontPinch(ui.FontPinchEvent{Scale: 0.1})
if TheState.appFontScale != MinAppFontScale {
t.Errorf("appFontScale=%v want MinAppFontScale %v", TheState.appFontScale, MinAppFontScale)
}
// Zoom in past the maximum.
HandleFontPinch(ui.FontPinchEvent{Scale: 100})
if TheState.appFontScale != MaxAppFontScale {
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
}
// At the maximum, further zoom-in is a no-op (including the scroll).
TheState.ScrollOffset = ui.Dp(123)
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5})
if TheState.appFontScale != MaxAppFontScale {
t.Errorf("appFontScale=%v want MaxAppFontScale %v", TheState.appFontScale, MaxAppFontScale)
}
if float64(TheState.ScrollOffset) != 123 {
t.Errorf("ScrollOffset=%v want 123 (unchanged at the clamp)", TheState.ScrollOffset)
}
}
func TestSetAppFontScale_Absolute(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0; TheState.ScrollOffset = 0 }()
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(50 * lh)
SetAppFontScale(2.0)
if TheState.appFontScale != 2.0 {
t.Fatalf("appFontScale=%v want 2.0", TheState.appFontScale)
}
if got, want := float64(TheState.ScrollOffset), 50*lh*2.0; math.Abs(got-want) > 1e-3 {
t.Errorf("ScrollOffset=%v want %v", got, want)
}
SetAppFontScale(0) // clamps to the minimum
if TheState.appFontScale != MinAppFontScale {
t.Errorf("appFontScale=%v want MinAppFontScale", TheState.appFontScale)
}
}
func TestHandleFontPinch_IgnoresBadData(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() { TheState.appFontScale = 1.0 }()
HandleFontPinch("not an event")
HandleFontPinch(ui.FontPinchEvent{Scale: 0})
HandleFontPinch(ui.FontPinchEvent{Scale: -2})
if TheState.appFontScale != 1.0 {
t.Errorf("appFontScale=%v want unchanged 1.0", TheState.appFontScale)
}
}
// The pinch CENTER stays fixed: the continuous content coordinate under the
// center (display-line units) is invariant through the scale change. A
// top-anchored zoom would move that coordinate by m*(1-ratio) display
// lines — for a center 400dp below the region top at ×1.5 that is ~59
// display lines off, which is exactly what this test rejects.
func TestHandleFontPinch_CenterAnchor(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.EditorRegion = ui.Region{}
}()
TheState.EditorRegion = ui.Region{Y: 100, H: 800}
lh := float64(EffectiveLineHeight())
TheState.ScrollOffset = ui.Dp(100 * lh)
// Center 400dp below the region top.
const m = 400.0
wantU := (float64(TheState.ScrollOffset) + m) / lh // content coord under the center
HandleFontPinch(ui.FontPinchEvent{Scale: 1.5, Center: ui.Point{Y: 100 + m}})
lhNew := float64(EffectiveLineHeight())
gotU := (float64(TheState.ScrollOffset) + m) / lhNew
// Tolerance is float32-scale + ui.Dp rounding accumulated over two
// conversions — still 4 orders of magnitude smaller than the ~59-line
// drift a top-anchored zoom would produce here.
if math.Abs(gotU-wantU) > 1e-4 {
t.Errorf("content under center: u=%v want %v (center must stay fixed)", gotU, wantU)
}
// Top-anchor sanity: the TOP moved (that is the point of center anchoring).
if float64(TheState.ScrollOffset) == 100*lh*1.5 {
t.Errorf("scroll=%v equals the top-anchored value; the center was not the anchor", TheState.ScrollOffset)
}
}
// With word wrap, the anchor must be the LOGICAL line under the center, not
// the display line: when the font change re-wraps the lines above the
// anchor (their fragment counts grow), the display-line index under the
// center changes, but the same logical line / fragment / sub-line must stay
// under it. applyFontPin is what the logic re-runs as rewrap corrections
// land (the font-pin).
func TestFontPin_SurvivesRewrap(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.ChunkedBuffer = nil
}()
// 1000 logical lines, each wrapped into 2 fragments at the current size.
w := NewWrapIndex(1000)
for i := 0; i < 1000; i++ {
w.Set(i, 2)
}
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
lh := float64(EffectiveLineHeight())
const m = 200.0 // region top (EditorRegion zero) + 200dp
// Anchor: logical line 60, its 1st fragment, 0.5 down it.
TheState.ScrollOffset = ui.Dp(float64(w.VisualsBefore(60))*lh + 0.5*lh - m)
line, frag, sub, ok := TheState.captureFontPin(m)
if !ok || line != 60 || frag != 0 || math.Abs(sub-0.5) > 1e-5 {
t.Fatalf("capture: line=%d frag=%d sub=%v ok=%v, want line 60 frag 0 sub 0.5", line, frag, sub, ok)
}
// The font grows ×1.5 (the line under the fingers keeps its 2 fragments
// for now): the same point stays under m.
TheState.appFontScale = 1.5
TheState.applyFontPin(line, frag, sub, m)
lhNew := float64(EffectiveLineHeight())
u := (float64(TheState.ScrollOffset) + m) / lhNew
if got := int(w.LineForVisual(int32(u))); got != 60 {
t.Errorf("after scale: line under center=%d want 60", got)
}
// Rewrap lands: every line now wraps into 3 fragments. The display-line
// index under the center moves from 120.5 to 180.5, but re-applying the
// pin must keep logical line 60 (fragment 0, sub 0.5) under the center.
for i := 0; i < 1000; i++ {
w.Set(i, 3)
}
TheState.applyFontPin(line, frag, sub, m)
u = (float64(TheState.ScrollOffset) + m) / lhNew
if got := int(w.LineForVisual(int32(u))); got != 60 {
t.Errorf("after rewrap: line under center=%d want 60 (display-line anchoring would fail here)", got)
}
// And a display-line anchor (what NOT to do) would be off: at the new
// counts, display line 120.5 is logical line 40, not 60.
if got := int(w.LineForVisual(120)); got == 60 {
t.Errorf("test is stale: display line 120 unexpectedly maps to line 60")
}
}
// fabLayout builds a GlyphLayout the way the shaper produces one: glyphsPerFrag
// glyphs per fragment, baseline of fragment f at (f+0.8)*lh (ascent < lh, the
// shaper's convention), 20dp advance each.
func fabLayout(lh float64, fragments, glyphsPerFrag int, byte0 int) ui.GlyphLayout {
gl := ui.GlyphLayout{LineHeight: ui.Dp(lh)}
for f := 0; f < fragments; f++ {
for g := 0; g < glyphsPerFrag; g++ {
gl.ByteOffsets = append(gl.ByteOffsets, byte0+f*glyphsPerFrag+g)
gl.X = append(gl.X, ui.Dp(10+20*float64(g)))
gl.Y = append(gl.Y, ui.Dp((float64(f)+0.8)*lh))
gl.Advance = append(gl.Advance, ui.Dp(20))
}
}
return gl
}
func TestGlyphAtLocalPoint(t *testing.T) {
gl := fabLayout(16.8, 2, 4, 0) // bytes 0-3 on fragment 0, 4-7 on fragment 1
if i, ok := glyphAtLocalPoint(gl, 50, 25); !ok || i != 6 {
t.Errorf("(50,25): i=%d ok=%v, want glyph 6 (byte 6, X=50 on fragment 1)", i, ok)
}
if _, ok := glyphAtLocalPoint(gl, 5, 25); ok {
t.Error("(5,25): left margin has no glyph, want not-ok")
}
if i, ok := glyphAtLocalPoint(gl, 1000, 25); !ok || i != 7 {
t.Errorf("(1000,25): past the line end pins its last glyph, got i=%d ok=%v want 7", i, ok)
}
if _, ok := glyphAtLocalPoint(ui.GlyphLayout{}, 50, 25); ok {
t.Error("empty layout: want not-ok")
}
}
func TestCaptureContentPin(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.GlyphLayout = ui.GlyphLayout{}
}()
TheState.Editor.GlyphLayout = fabLayout(16.8, 2, 4, 0)
TheState.ScrollOffset = 0
// Point at (x=50, y=25): fragment 1 (baseline 30.24), the glyph at X=50.
pin := TheState.captureContentPin(50, 25)
if !pin.HaveGlyph || pin.Byte != 6 {
t.Fatalf("pin: byte=%d haveGlyph=%v, want byte 6", pin.Byte, pin.HaveGlyph)
}
if math.Abs(pin.Dy-(25-30.24)) > 1e-5 { // ui.Dp stores float32-precision values
t.Errorf("dy=%v want %v (offset from the glyph baseline)", pin.Dy, 25-30.24)
}
// Fallback anchor (no WrapIndex): line 1, sub = 25/16.8 - 1 ~ 0.488.
if pin.Line != 1 || pin.Frag != 0 || math.Abs(pin.Sub-(25/16.8-1)) > 1e-6 {
t.Errorf("fallback: line=%d frag=%d sub=%v, want line 1 frag 0 sub %v", pin.Line, pin.Frag, pin.Sub, 25/16.8-1)
}
}
// The REFINEMENT is what makes the content point stay fixed across a rewrap:
// the pinned byte moves to a different fragment in the new layout, and the
// refined offset places that byte's baseline (plus the captured dy) exactly
// at the pinch center — where the (line, fragment) anchor would leave a
// different character there.
func TestRefineContentPin_RewrapKeepsContentPoint(t *testing.T) {
const m = 25.0 // region-relative pinch center
const dy = 25.0 - 30.24 // from TestCaptureContentPin's point (above baseline)
// OLD layout (font 1.0x): byte 6 on fragment 1. (Capture would have
// returned Byte=6, Dy=dy.)
_ = fabLayout(16.8, 2, 4, 0)
// Font grows to 1.5x: the line re-wraps to 3 fragments x 3 glyphs; byte 6
// lands on fragment 2 (a DIFFERENT fragment than the old fragment 1 it
// was captured on... old frag index was 1, new is 2).
glNew := fabLayout(25.2, 3, 3, 0)
// The new layout is shaped for the window starting at document byte 0,
// whose first visual line is visual line 3 of the document (vk=3):
// the window top sits at content 3*25.2.
vk := 3
off, ok := refineContentPin(glNew, vk, 0, dy, m, 6)
if !ok {
t.Fatal("refine: want ok")
}
// The pinned point (byte 6's baseline + dy) must sit exactly at m.
contentY := float64(off) + m // content coordinate of the region-relative m
// byte 6's baseline: window y = (2+0.8)*25.2, window top at content vk*lh.
baselineContent := float64(vk)*float64(glNew.LineHeight) + float64(glNew.Y[6])
if math.Abs(baselineContent+dy-contentY) > 1e-5 { // float32-precision layout Y
t.Errorf("pinned point at content %v, want %v (baseline %v + dy)", contentY, baselineContent+dy, baselineContent)
}
// (A (line, fragment) anchor would have failed here: the byte captured on
// the old fragment 1 sits on the NEW fragment 2, a full line further down.
// Pinning the old fragment index would leave a different character at the
// center — exactly the drift the content pin exists to remove.)
}
// Steady state: a layout shaped at offset S with the pinned point at the
// center must refine back to exactly S (the fixed point — no drift, no
// oscillation while the pin is armed).
func TestRefineContentPin_FixedPoint(t *testing.T) {
const m = 25.0
lh := ui.Dp(25.2)
S := ui.Dp(100)
vk, r := scrollDecompose(S, lh) // window's first visual line = v0 (no wrap)
const dy = 3.0
// Place byte 4 at window y = m + r - dy so the point sits at m when the
// window (top at content vk*lh) is shaped at S.
wantY := m + r - dy
gl := ui.GlyphLayout{LineHeight: lh}
for b := 0; b < 6; b++ {
gl.ByteOffsets = append(gl.ByteOffsets, b)
gl.X = append(gl.X, ui.Dp(10+20*float64(b%3)))
gl.Y = append(gl.Y, ui.Dp(wantY)) // one line's worth for this test
gl.Advance = append(gl.Advance, ui.Dp(20))
}
off, ok := refineContentPin(gl, int(vk), 0, dy, m, 4)
if !ok {
t.Fatal("refine: want ok")
}
if math.Abs(float64(off)-float64(S)) > 1e-9 {
t.Errorf("fixed point: refine(S)=%v want S=%v (no drift)", off, S)
}
}
// Pinch-out clamp of the pinned fragment: a line that re-wraps to FEWER
// fragments must not pin a fragment that no longer exists.
func TestFontPin_FragmentClampedOnPinchOut(t *testing.T) {
TheLogic = nil
TheState = NewState()
defer func() {
TheState.appFontScale = 1.0
TheState.ScrollOffset = 0
TheState.Editor.ChunkedBuffer = nil
}()
w := NewWrapIndex(100)
for i := 0; i < 100; i++ {
w.Set(i, 3)
}
TheState.Editor.ChunkedBuffer = &ChunkedBuffer{WrapIndex: w}
// Pin line 5, fragment 2 (the third fragment).
TheState.appFontScale = 1.0
const m = 100.0
TheState.applyFontPin(5, 2, 0.25, m)
// Pinch out: the line now wraps into 2 fragments.
for i := 0; i < 100; i++ {
w.Set(i, 2)
}
TheState.appFontScale = 0.8
TheState.applyFontPin(5, 2, 0.25, m) // frag 2 must clamp to 1
lh := float64(EffectiveLineHeight())
u := (float64(TheState.ScrollOffset) + m) / lh
// The anchor is now line 5, its LAST fragment (index 1), 0.25 down it.
wantU := float64(w.VisualsBefore(5)+1) + 0.25
if math.Abs(u-wantU) > 1e-6 {
t.Errorf("anchor u=%v want %v (last remaining fragment of line 5)", u, wantU)
}
}

View File

@ -20,45 +20,45 @@ func absFloat(x ui.Dp) float64 {
func TestFragmentStartYCalculation(t *testing.T) {
// Setup test scenario
lineHeight := ui.Dp(16.8) // 14 * 1.2
// Test case 1: Scroll to line 0 (top)
scrollOffset := ui.Dp(0)
expectedVisualLine := 0
expectedFragmentStartY := ui.Dp(0)
visualLine := int(scrollOffset / lineHeight)
fragmentStartY := ui.Dp(visualLine) * lineHeight
if visualLine != expectedVisualLine {
t.Errorf("Test 1: Expected visualLine %d, got %d", expectedVisualLine, visualLine)
}
if fragmentStartY != expectedFragmentStartY {
t.Errorf("Test 1: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY)
}
// Test case 2: Scroll to line 1
scrollOffset = ui.Dp(16.8)
expectedVisualLine = 1
expectedFragmentStartY = ui.Dp(16.8)
visualLine = int(scrollOffset / lineHeight)
fragmentStartY = ui.Dp(visualLine) * lineHeight
if visualLine != expectedVisualLine {
t.Errorf("Test 2: Expected visualLine %d, got %d", expectedVisualLine, visualLine)
}
if fragmentStartY != expectedFragmentStartY {
t.Errorf("Test 2: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY)
}
// Test case 3: Scroll to line 2
scrollOffset = ui.Dp(33.6)
expectedVisualLine = 2
expectedFragmentStartY = ui.Dp(33.6)
visualLine = int(scrollOffset / lineHeight)
fragmentStartY = ui.Dp(visualLine) * lineHeight
if visualLine != expectedVisualLine {
t.Errorf("Test 3: Expected visualLine %d, got %d", expectedVisualLine, visualLine)
}
@ -77,24 +77,24 @@ func TestVisualLineIndexCreation(t *testing.T) {
Advance: []ui.Dp{10, 10, 10, 10},
LineHeight: ui.Dp(16.8),
}
// Build visual line index
var visualLineOffsets []int32
visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[0]))
for i := 1; i < len(layout.Y); i++ {
if layout.Y[i] != layout.Y[i-1] {
// New visual line starts at this glyph
visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[i]))
}
}
// With 4 glyphs at different Y positions, we get 4 visual line starts
// This is actually correct - each glyph that starts a new line is a visual line start
if len(visualLineOffsets) != 4 {
t.Errorf("Expected 4 visual line starts, got %d", len(visualLineOffsets))
}
// Check byte offsets - should match all the byte offsets where Y changes
expectedOffsets := []int32{0, 7, 14, 21}
for i := 0; i < len(visualLineOffsets) && i < len(expectedOffsets); i++ {
@ -107,14 +107,14 @@ func TestVisualLineIndexCreation(t *testing.T) {
// TestWordWrapFragmentStartY tests fragmentStartY calculation with word wrap enabled
func TestWordWrapFragmentStartY(t *testing.T) {
lineHeight := ui.Dp(16.8) // 14 * 1.2
// Test case: Word wrap enabled, scroll to visual line 5
// With word wrap, visual lines don't correspond 1:1 with logical lines
wordWrap := true
_ = wordWrap // Mark as used for this test
_ = wordWrap // Mark as used for this test
scrollOffset := ui.Dp(5 * float64(lineHeight)) // Scroll to visual line 5
visualLine := int(scrollOffset / lineHeight)
// With word wrap, we should use a different estimation
// because one logical line can span multiple visual lines
if wordWrap {
@ -122,7 +122,7 @@ func TestWordWrapFragmentStartY(t *testing.T) {
// to avoid skipping wrapped lines
estimatedBytesPerVisualLine := 10 // Very small step
expectedStartByteOffset := visualLine * estimatedBytesPerVisualLine
if expectedStartByteOffset != 50 { // 5 * 10
t.Errorf("Word wrap case: expected start byte offset %d, got %d", 50, expectedStartByteOffset)
}
@ -130,12 +130,12 @@ func TestWordWrapFragmentStartY(t *testing.T) {
// Without word wrap, use bytes per logical line estimate
estimatedBytesPerLogicalLine := 50
expectedStartByteOffset := visualLine * estimatedBytesPerLogicalLine
if expectedStartByteOffset != 250 { // 5 * 50
t.Errorf("No word wrap case: expected start byte offset %d, got %d", 250, expectedStartByteOffset)
}
}
// fragmentStartY should always be visualLine * lineHeight
expectedFragmentStartY := ui.Dp(visualLine) * lineHeight
if expectedFragmentStartY != ui.Dp(5*16.8) {
@ -150,31 +150,31 @@ func TestByteOffsetAndYFromScroll(t *testing.T) {
visualLineIndex := &types.VisualLineIndex{
Offsets: []int32{0, 7, 14, 21}, // 4 lines
}
layout := ui.GlyphLayout{
ByteOffsets: []int{0, 7, 14, 21},
X: []ui.Dp{0, 0, 0, 0},
Y: []ui.Dp{0, 16.8, 33.6, 50.4},
Advance: []ui.Dp{10, 10, 10, 10},
LineHeight: lineHeight,
ByteOffsets: []int{0, 7, 14, 21},
X: []ui.Dp{0, 0, 0, 0},
Y: []ui.Dp{0, 16.8, 33.6, 50.4},
Advance: []ui.Dp{10, 10, 10, 10},
LineHeight: lineHeight,
VisualLineIndex: visualLineIndex,
}
// Test scrolling to different positions
testCases := []struct {
scrollOffset ui.Dp
expectedByte int
expectedY ui.Dp
scrollOffset ui.Dp
expectedByte int
expectedY ui.Dp
}{
{ui.Dp(0), 0, ui.Dp(0)}, // Top of document
{ui.Dp(16.8), 7, ui.Dp(16.8)}, // Start of line 1
{ui.Dp(33.6), 14, ui.Dp(33.6)}, // Start of line 2
{ui.Dp(50.4), 21, ui.Dp(50.4)}, // Start of line 3
{ui.Dp(0), 0, ui.Dp(0)}, // Top of document
{ui.Dp(16.8), 7, ui.Dp(16.8)}, // Start of line 1
{ui.Dp(33.6), 14, ui.Dp(33.6)}, // Start of line 2
{ui.Dp(50.4), 21, ui.Dp(50.4)}, // Start of line 3
}
for _, tc := range testCases {
byteOffset, y := ByteOffsetAndYFromScrollWithLayout(tc.scrollOffset, layout)
if byteOffset != tc.expectedByte {
t.Errorf("Scroll %v: expected byte offset %d, got %d", tc.scrollOffset, tc.expectedByte, byteOffset)
}
@ -191,17 +191,17 @@ func ByteOffsetAndYFromScrollWithLayout(scrollOffset ui.Dp, layout ui.GlyphLayou
if lineHeight == 0 {
lineHeight = editor.EditorLineHeight()
}
// Calculate which visual line should be at the given scroll offset
visualLine := int(scrollOffset / lineHeight)
// If we have a visual line index, use it for accurate byte offset
if layout.VisualLineIndex != nil && visualLine < len(layout.VisualLineIndex.Offsets) {
byteOffset := int(layout.VisualLineIndex.Offsets[visualLine])
lineTop := ui.Dp(visualLine) * lineHeight
return byteOffset, lineTop
}
// Fallback to the original logic using layout.Y values
// 1. Find the index of the line whose top is <= scrollOffset
idx := sort.Search(len(layout.Y), func(i int) bool {
@ -226,4 +226,4 @@ func ByteOffsetAndYFromScrollWithLayout(scrollOffset ui.Dp, layout ui.GlyphLayou
lineTop := layout.Y[lineStartIdx] - lineHeight
return layout.ByteOffsets[lineStartIdx], lineTop
}
}

View File

@ -55,6 +55,16 @@ type FindState struct {
// and the logic storing it (a round-trip away), wiping the input while
// typing.
ClearSeq int
// --- Relaunch restoration (spec §7, see session.go) ---
// Restoring is set while a restored session's find scan is in flight:
// its first result selects the restored current match (RestoreMatch) but
// does NOT scroll — the viewport stays where the restored scroll offset
// put it. applySearchResult consumes both; findReset drops them.
Restoring bool
// RestoreMatch is the byte offset of the pre-launch current find match
// (the snapshot's FindCurByte); -1 = none.
RestoreMatch int
}
// ToggleFind opens the find bar (or closes it when open). It is the tap
@ -205,18 +215,41 @@ func (e *EditorState) applySearchResult(res pool.Result) {
f.Cur = i
}
}
// Relaunch restore: select the pre-launch current match by byte
// offset (spec §7) — the one containing the byte, else the first
// match after it, else the last (its text may have been edited
// away, so a fallback is always defined).
if f.Cur < 0 && f.RestoreMatch >= 0 {
i := sort.Search(len(f.Matches), func(i int) bool {
return f.Matches[i][0] > f.RestoreMatch
})
switch {
case i > 0 && f.Matches[i-1][1] > f.RestoreMatch:
f.Cur = i - 1
case i < len(f.Matches):
f.Cur = i
default:
f.Cur = len(f.Matches) - 1
}
}
if f.Cur < 0 {
f.Cur = 0
}
// The first discovery of matches (matches went 0 -> N) selects and
// scrolls the view to the current match; while the user keeps
// typing, results update in place and the view moves only on
// explicit next/prev.
// The first discovery of matches (matches went 0 -> N) selects the
// current match and scrolls the view to it — except during a
// relaunch restore (Restoring), where the viewport must stay where
// the restored scroll offset put it. While the user keeps typing,
// results update in place and the view moves only on explicit
// next/prev.
if wasEmpty {
SetSelection(f.Matches[f.Cur][0], f.Matches[f.Cur][1])
scrollToFindMatch(f.Matches[f.Cur][0])
if !f.Restoring {
scrollToFindMatch(f.Matches[f.Cur][0])
}
}
}
f.Restoring = false
f.RestoreMatch = -1
}
// findStep selects the next (dir>0) or previous (dir<0) match, wrapping
@ -322,6 +355,9 @@ func scrollToFindMatch(absByte int) {
e.Find.SettleByte = -1
return
}
if TheLogic != nil {
TheLogic.releaseRestorePin() // search takes over the viewport
}
TheState.ScrollOffset = target
if e.Find.Visible {
e.Find.SettleByte = absByte

518
internal/editor/session.go Normal file
View File

@ -0,0 +1,518 @@
// State restoration on relaunch (spec §7).
//
// The restorable state is a small snapshot (SessionState) the logic
// goroutine takes on demand: the last opened file, the cursor byte offset,
// the editor scroll offset, the live selection, and the find bar (query,
// open/closed, current match). The search RESULTS themselves are not part
// of the snapshot: on relaunch they are regenerated by re-scanning the
// restored query, and the current match is re-selected by byte offset
// (FindState.Restoring/RestoreMatch), without re-scrolling the restored
// viewport.
//
// Ownership (architecture.md §1): the logic goroutine owns the snapshot
// content. The cmd layer (cmd/pad/main.go) owns the tiny JSON file that
// stores it and registers the writer (Logic.SetSessionSaver); the logic
// calls it rate-limited from emitFrame and unconditionally at Shutdown, so
// the file is current even if the process is killed shortly after a change.
// On startup the cmd layer reads the file and hands it to Logic.BeginRestore
// (before Run, single-threaded), which re-opens the file and shows the
// editor immediately; the cursor/selection/find state land as the file's
// stat and content arrive (handleWorkerResult). A restore whose file is
// gone falls back to the browser page.
package editor
import (
"log"
"math"
"time"
"pad/internal/ui"
)
// sessionSaveInterval rate-limits the periodic session save. The file is
// tiny (a few hundred bytes), so a few writes a second at most is
// negligible. The window bounds how stale the persisted state can be if
// the process is killed (recents-wipe) right after a change: at 1s, a
// kill less than a second after the last edit lost the cursor, selection
// and final scroll. See saveSessionIfChanged for the urgent path.
const sessionSaveInterval = 250 * time.Millisecond
// SessionState is the relaunch snapshot (see the file doc above). All
// positions are absolute file byte offsets; Scroll is in Dp. It is a plain
// comparable value (used with == for change detection).
//
// Scroll is backed by ScrollLine: the pixel offset lives in VISUAL-line
// space (a wrapped line occupies several visual lines, and the scroll-to-
// line mapping runs through the WrapIndex), but on relaunch the counts of
// every line above the restored viewport are still the estimate (1) until
// the line is shaped, and lines above the visible window are never shaped.
// Mapping the saved offset through that fresh index would land a logical
// line DEEPER by all the wrapped continuations above the viewport, with no
// self-correction. The logical line at the viewport top and the sub-line
// remainder are wrap-independent, so they are the restorable unit; Scroll
// is kept as the pre-line-coordinate value (legacy session files).
type SessionState struct {
File string // last opened file ("" = nothing to restore)
Cursor int // cursor byte offset
Scroll float64 // editor scroll offset, Dp
ScrollLine int // logical line at the viewport top (-1 = unknown)
ScrollSub float64 // Scroll's sub-line remainder as a FRACTION of the line height (0 <= f < 1); font-independent. Snapshots written before the pinch feature stored Dp instead; restore converts values > 1.
SelStart int // selection start byte (-1 = no selection)
SelEnd int // selection end byte, exclusive
FindQuery string // find bar query ("" = none)
FindVisible bool // find bar was open
FindCurByte int // start byte of the current find match (-1 = none)
// AppFontScale is the app-local pinch font scale (0 = default 1.0).
AppFontScale float64
}
// SetSessionSaver registers the callback that persists snapshots (the cmd
// layer's JSON file writer). It is invoked from the logic goroutine:
// rate-limited from emitFrame, and unconditionally at Shutdown. The
// callback must be fast (a tiny file write).
func (l *Logic) SetSessionSaver(f func(SessionState)) {
l.sessionSaver = f
}
// SnapshotSession captures the current relaunch snapshot. The cursor is
// clamped to the file length; the selection and current find match are -1/
// -1 when inactive. Must be called on the logic goroutine (or after it has
// fully exited, as Shutdown does).
func (l *Logic) SnapshotSession() SessionState {
s := l.state
e := &s.Editor
cur := e.CursorPosition
if cb := e.ChunkedBuffer; cb != nil {
if fl := int(cb.FileLen()); cur > fl {
cur = fl
}
}
f := &e.Find
curByte := -1
if f.Cur >= 0 && f.Cur < len(f.Matches) {
curByte = f.Matches[f.Cur][0]
}
// Persist the scroll in wrap-independent coordinates (see the
// SessionState doc). The derivation mirrors visibleByteRangePrecise
// (scrollDecompose, then LineForVisual against the CURRENT index), so
// the line is exactly the one the layout is showing at this offset.
scrollLine := -1
var scrollSub float64
if cb := e.ChunkedBuffer; cb != nil && !e.TooLarge {
lh := EffectiveLineHeight()
if gl := e.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
v0, r0 := scrollDecompose(s.ScrollOffset, lh)
scrollLine = int(v0)
if w := cb.WrapIndex; w != nil {
scrollLine = w.LineForVisual(int32(v0))
}
// Store the sub-line remainder as a fraction of the line height so
// it stays valid if the font scale changes between save and restore
// (r0 < lh always, so the fraction is < 1 — that is also the legacy
// Dp discriminator used on restore).
scrollSub = r0 / float64(lh)
}
return SessionState{
File: e.Filename,
Cursor: cur,
Scroll: float64(s.ScrollOffset),
ScrollLine: scrollLine,
ScrollSub: scrollSub,
SelStart: e.SelectionStart,
SelEnd: e.SelectionEnd,
FindQuery: f.Query,
FindVisible: f.Visible,
FindCurByte: curByte,
AppFontScale: float64(s.appFontScale),
}
}
// saveSessionIfChanged persists the snapshot when it differs from the last
// saved one and the rate limit has elapsed. Must be called on the logic
// goroutine (it is called from emitFrame).
//
// While a restore is still pending (l.session set, not yet landed or
// abandoned) saves are suppressed: the pre-land snapshot has zeroed
// cursor/selection, and persisting it would clobber the positions being
// restored if the process is killed during startup.
func (l *Logic) saveSessionIfChanged() {
if l.sessionSaver == nil {
return
}
if l.session.File != "" {
return
}
now := time.Now()
s := l.SnapshotSession()
if s == l.lastSession {
return
}
// Save immediately when the file changes or a selection appears or
// vanishes — low-frequency, high-value changes (the user just opened a
// file or highlighted/cleared text). Everything else (scroll ticks,
// cursor moves, selection-handle drags) is high-frequency and stays
// rate-limited; a kill within the window then loses at most a fraction
// of a second of motion, not the edit state.
urgent := s.File != l.lastSession.File ||
(s.SelStart >= 0) != (l.lastSession.SelStart >= 0)
if !urgent && now.Sub(l.lastSessionSave) < sessionSaveInterval {
return
}
l.writeSession(s, now)
}
// flushSessionSave persists the current snapshot now, bypassing the rate
// limit (still honoring the restore-pending suppression: a pre-land
// snapshot would clobber the positions being restored). Must be called on
// the logic goroutine.
func (l *Logic) flushSessionSave() {
if l.sessionSaver == nil || l.session.File != "" {
return
}
s := l.SnapshotSession()
if s == l.lastSession {
return
}
l.writeSession(s, time.Now())
}
// writeSession is the shared persist tail: hand the snapshot to the cmd
// layer's saver and record it as the new baseline. Must be called on the
// logic goroutine.
func (l *Logic) writeSession(s SessionState, now time.Time) {
l.sessionSaver(s)
l.lastSession = s
l.lastSessionSave = now
}
// FlushSession requests an immediate session persist from any goroutine.
// The Android activity's onStop (recents-wipe, app switch) calls it via
// JNI: the OS gives no user-space hook for the subsequent process kill,
// so onStop is the last reliable moment to flush. Buffered delivery means
// the caller never blocks; if an earlier flush is still queued, one
// snapshot is coalesced into it (the state is the same frame's anyway).
func (l *Logic) FlushSession() {
select {
case l.flushSession <- struct{}{}:
default:
}
}
// BeginRestore prepares the state for relaunch restoration: the last file
// is re-opened and the editor page shown immediately. The cursor,
// selection, find state and scroll land as the file's stat/content arrive
// (handleWorkerResult); the scroll offset is applied now and clamped by
// EditorLayout once the line index sets the real MaxScroll. Must be called
// after NewLogic and before Run (the cmd layer does both before starting
// the logic goroutine), so it touches state single-threaded.
func (l *Logic) BeginRestore(s SessionState) {
if s.File == "" {
return
}
// The zero-value ScrollLine (0) is ambiguous: a genuine top-of-file
// snapshot always has Scroll < lineHeight (no line above line 0
// contributes visual lines), so line 0 with a deep offset is an unset
// field (an in-process caller or test built the snapshot without it) —
// fall back to the pixel offset rather than snapping to the top.
if s.ScrollLine == 0 && s.Scroll >= 2*float64(EffectiveLineHeight()) {
s.ScrollLine = -1
}
l.session = s
e := &l.state.Editor
e.Filename = s.File
e.CursorPosition = 0 // the restored cursor lands with the content
e.TooLarge = false
e.TooLargeSize = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
// Fresh per-file find state; the snapshot's query and visibility land
// with the stat (the file must exist first).
e.findReset()
// The scroll offset is armed, not applied: it lands in the emitFrame
// hook, once the layout pass has a trustworthy viewport (see the Logic
// struct).
l.restoreScroll = ui.Dp(s.Scroll)
l.restoreScrollArmed = s.Scroll > 0
// The app-local font scale lands immediately, BEFORE any layout: the
// restore's line-based offset math reads it through EffectiveLineHeight.
if as := s.AppFontScale; as > 0 {
if as < MinAppFontScale {
as = MinAppFontScale
}
if as > MaxAppFontScale {
as = MaxAppFontScale
}
l.state.appFontScale = float32(as)
}
l.state.page = EditorPage
l.state.FocusedElementID = "editor_text"
l.state.justOpenedAt = time.Now()
l.emitFrame() // show the editor page now; the content loads async
}
// abortRestore ends a restore without landing it (the user opened another
// file, the read failed, or the file is too large): clears the pending
// snapshot so session saving resumes (see saveSessionIfChanged). The editor
// state itself is left alone. Must be called on the logic goroutine.
func (l *Logic) abortRestore() {
l.restoreFile = ""
l.restoreScrollArmed = false
l.restoreContentLanded = false
l.restoreScrollLine = -1
l.session = SessionState{}
}
// maybeApplyRestoreScroll lands the armed restore scroll offset once the
// viewport is trustworthy, clamped to the current MaxScroll (the layout
// clamps again if the line index shrinks it later). It is called from
// emitFrame AFTER the layout pass, because that pass is where MaxScroll is
// computed for the current viewport — applying the offset before it (or in a
// pass with an unknown viewport) would hit the one-way clamp and lose the
// offset. On device the first ScaleEvent can precede the size ConfigEvent
// (and both can precede the restored content), so the offset stays armed
// until scale, size and editor content are all known; it is applied on the
// first such emitFrame. restoreContentLanded (not FileLen, which the stat
// result sets before the content arrives) marks that the buffer holds the
// restored file; clearing restoreFile any earlier would make the read
// result's path guard miss the just-loaded file. Returns true if it
// applied. Must be called on the logic goroutine.
func (l *Logic) maybeApplyRestoreScroll() bool {
if !l.restoreScrollArmed {
return false
}
if !l.scaleSeen {
return false
}
if l.state.PixelWidth <= 0 || l.state.scale <= 0 {
return false
}
if l.state.Page() != EditorPage || !l.restoreContentLanded {
return false
}
// Re-derive the offset from the persisted logical line (see the
// SessionState doc): the snapshot's pixel offset mapped to its position
// only while the saving session's WrapIndex was valid. On relaunch the
// counts above the restored viewport are all the estimate until shaped,
// and those lines never are (shaping covers the visible window only),
// so the raw offset would land a line deeper by every wrapped
// continuation above the viewport. The line-based offset lands on the
// saved line regardless of wrap state.
s := l.session
if s.ScrollLine >= 0 {
lh := EffectiveLineHeight()
if gl := l.state.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
// The offset for a logical line is V(L)*lh + sub under the CURRENT
// WrapIndex (V(L) = L while the index is all estimates). Wrapping
// below the line was already corrected before the offset could be
// applied (pre-apply frames shaped the top-of-file window), so
// mapping through V(L) lands on the line under either state.
base := float64(s.ScrollLine)
if cb := l.state.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && s.ScrollLine < cb.WrapIndex.Len() {
base = float64(cb.WrapIndex.VisualsBefore(s.ScrollLine))
}
sub := s.ScrollSub
if sub > 1 {
// Legacy snapshot: the sub-line remainder was stored in Dp, not
// as a fraction. Convert with the current line height (the
// error is a fraction of one sub-line line, at most).
sub = sub / float64(lh)
}
l.restoreScroll = ui.Dp(base*float64(lh) + sub*float64(lh))
// Arm the line-pin (see refreshRestorePin): wrap-count corrections
// landing below this line would shift the mapping and drag the
// viewport off the restored line while the index settles.
l.restoreScrollLine = s.ScrollLine
l.restoreScrollSub = sub // fraction of the line height
l.restorePinDeadline = time.Now().Add(restorePinTimeout)
} else {
l.restoreScrollLine = -1
}
if l.restoreScroll > l.state.MaxScroll {
l.restoreScroll = l.state.MaxScroll
}
l.state.ScrollOffset = l.restoreScroll
l.restoreScrollArmed = false
// The snapshot has fully landed (cursor/selection/find with the
// content, scroll now): lift the save suppression.
l.session = SessionState{}
return true
}
// restorePinTimeout bounds the line-pin: after this long the index around
// the restored window has either settled or the user has moved on.
const restorePinTimeout = 2 * time.Second
// releaseRestorePin clears the restore line-pin (user or search took over
// the viewport). Must be called on the logic goroutine.
func (l *Logic) releaseRestorePin() {
l.restoreScrollLine = -1
}
// refreshRestorePin re-derives the scroll offset from the pinned line under
// the CURRENT WrapIndex (V(L)*lh + sub, the same mapping the layout uses),
// so wrap-count corrections landing below the pinned line cannot drag the
// viewport off it. Releases the pin when the deadline passes. Must be
// called on the logic goroutine, after a wrap correction has been applied.
func (l *Logic) refreshRestorePin() {
if l.restoreScrollLine < 0 {
return
}
if time.Now().After(l.restorePinDeadline) {
l.restoreScrollLine = -1
return
}
cb := l.state.Editor.ChunkedBuffer
if cb == nil || cb.WrapIndex == nil {
return
}
w := cb.WrapIndex
if l.restoreScrollLine >= w.Len() {
return
}
lh := EffectiveLineHeight()
if gl := l.state.Editor.GlyphLayout; gl.LineHeight > 0 {
lh = gl.LineHeight
}
// No MaxScroll clamp here: the correction that triggered this refresh
// just grew the index, so the layout's MaxScroll (computed before it) is
// stale and would under-clamp the re-derived offset; the layout pass of
// the emitted frame clamps to the fresh value. An edit shrinking the
// file mid-pin is covered the same way.
off := ui.Dp(float64(w.VisualsBefore(l.restoreScrollLine))*float64(lh) + l.restoreScrollSub*float64(lh))
if off != l.state.ScrollOffset {
l.state.ScrollOffset = off
l.emitFrame()
}
}
// fontPinTimeout bounds the pinch font-pin: rewrap corrections keep landing
// for a while after the last pinch frame (shaping lags the font change);
// after this long the user has moved on and the pin stands down.
const fontPinTimeout = 2 * time.Second
// setFontPin arms/updates the pinch font-pin from a pre-change capture
// (see captureContentPin: the glyph under the pinch center + offset from its
// baseline) and applies the IMMEDIATE anchor: the continuous content
// coordinate under the center scaled by the font ratio. Exact until the
// rewrap lands; the shaped-layout feedback (refreshFontPin) then snaps the
// pinned character exactly onto the center. Must be called on the logic
// goroutine.
func (l *Logic) setFontPin(pin contentPin, m float64, ratio float64) {
l.fontPin = pin
l.fontPinM = m
l.fontPinArmed = true
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
s := l.state
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
// No emitFrame here: the caller (input path or debug command) emits the
// frame carrying the new scale and the rescaled offset.
}
// refreshFontPin re-derives the scroll offset from a freshly shaped layout so
// the pinned content point stays under the pinch center (see
// refineContentPin). Runs on every layout feedback while armed: the font
// change's own re-shaping is the first, and rewrap corrections follow it. If
// the pinned byte is not in the shaped window (no layout yet, or the point
// is in a blank margin), the (line, fragment, sub-line) fallback anchor
// stands in. An edit landing since the capture (EditSeq mismatch) or leaving
// the editor page disarms the pin: its byte no longer names the same content.
// No MaxScroll clamp: the layout pass of the emitted frame clamps to the
// fresh value. Must be called on the logic goroutine.
func (l *Logic) refreshFontPin(fb ui.LayoutFeedback) {
if !l.fontPinArmed {
return
}
if time.Now().After(l.fontPinDeadline) {
l.fontPinArmed = false
return
}
s := l.state
if s.page != EditorPage || fb.EditSeq != l.fontPin.EditSeq {
l.fontPinArmed = false
return
}
l.fontPinDeadline = time.Now().Add(fontPinTimeout)
// Skip feedback shaped at a DIFFERENT scale: during a pinch every frame
// changes the scale, so all but the latest feedback carry layouts whose
// LineHeight no longer matches the geometry the pin computes in. A
// stale-scale layout would place the point by the old geometry for one
// frame (a visible jump) before the next corrects it.
if fb.GlyphLayout.LineHeight > 0 &&
math.Abs(float64(fb.GlyphLayout.LineHeight)-float64(EffectiveLineHeight())) > 0.5 {
return
}
old := s.ScrollOffset
vk := -1
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && fb.WindowStartLine >= 0 {
vk = int(cb.WrapIndex.VisualsBefore(fb.WindowStartLine))
}
if l.fontPin.HaveGlyph && vk >= 0 {
if off, ok := refineContentPin(fb.GlyphLayout, vk, fb.WindowStartByte, l.fontPin.Dy, l.fontPinM, l.fontPin.Byte); ok {
s.ScrollOffset = off
} else {
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
}
} else {
s.applyFontPin(l.fontPin.Line, l.fontPin.Frag, l.fontPin.Sub, l.fontPinM)
}
if s.ScrollOffset != old {
l.emitFrame()
}
}
// releaseFontPin disarms the pinch font-pin: the user (a scroll, a debug
// command, a file switch) has taken over the viewport. Must be called on
// the logic goroutine.
func (l *Logic) releaseFontPin() {
l.fontPinArmed = false
}
// applyRestorePositions clamps the restored snapshot's cursor and selection
// to a file of n bytes and applies them. Must be called on the logic
// goroutine.
func (l *Logic) applyRestorePositions(n int) {
s := l.session
e := &l.state.Editor
if c := s.Cursor; c >= 0 {
if c > n {
c = n
}
e.CursorPosition = c
}
if ss, se := s.SelStart, s.SelEnd; ss >= 0 && se > ss {
if se > n {
se = n
}
if ss < se {
e.SelectionAnchor = ss
e.SelectionStart = ss
e.SelectionEnd = se
}
}
}
// abandonRestore gives up on the relaunch restore (the file's stat failed:
// it was deleted or moved since the last session): reset the editor and
// land on the browser page, as if nothing had been restored. Must be called
// on the logic goroutine.
func (l *Logic) abandonRestore() {
l.abortRestore()
e := &l.state.Editor
log.Printf("Logic: cannot restore %q; starting in the browser", e.Filename)
e.Filename = ""
e.ChunkedBuffer = nil
e.CursorPosition = 0
e.SelectionAnchor = -1
e.SelectionStart = -1
e.SelectionEnd = -1
e.Find = FindState{SettleByte: -1}
l.state.page = BrowserPage
l.state.ScrollOffset = 0
l.state.FocusedElementID = ""
}

View File

@ -17,6 +17,15 @@ import (
// EditorFontSize is the font size used for editor text.
const EditorFontSize = 14 // unit.Sp
// App-local font-size scale bounds (pinch zoom), applied on top of the
// system user font scale. The scale itself is a continuous float32 — it is
// never rounded to a whole point value; the bounds only stop the pinch from
// leaving the usable range.
const (
MinAppFontScale = 0.5
MaxAppFontScale = 3.0
)
// EditorLineHeightScale is the baseline-to-baseline spacing multiplier.
const EditorLineHeightScale = 1.2
@ -190,6 +199,7 @@ type State struct {
PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32
fontScale float32 // user font-size setting (PxPerSp/PxPerDp); 0 = unknown -> 1.0
appFontScale float32 // app-local pinch font scale (1.0 = default); 0 = unknown -> 1.0
page Page // current page (Browser or Editor)
WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp
@ -232,6 +242,7 @@ type State struct {
func NewState() *State {
return &State{
scale: 1.0,
appFontScale: 1.0,
page: BrowserPage, // Reverted to BrowserPage
WordWrap: true, // Enable word wrap by default
lastEvictionTime: time.Now(),
@ -260,6 +271,11 @@ func (s *State) SetFontScale(fs float32) {
s.fontScale = fs
}
// Page returns the current page (BrowserPage or EditorPage).
func (s *State) Page() Page {
return s.page
}
func (s *State) Scale() float32 {
return s.scale
}
@ -273,17 +289,306 @@ func stateFontScale() float32 {
}
// EffectiveLineHeight is the editor line height in density-dp WITH the user
// font-size setting applied. The shaper draws baselines at
// Sp(EditorFontSize*LineHeightScale) physical px, which is
// EditorLineHeight()*fontScale density-dp. Every piece of geometry
// bookkeeping (window start, sub-line remainder, tap mapping, scroll
// clamping, cursor vertical move) must use this value rather than the raw
// EditorLineHeight; at a non-default font setting the two differ by the
// font-size setting AND the app-local pinch scale applied. The shaper draws
// baselines at Sp(EditorFontSize*appFontScale*LineHeightScale) physical px,
// which is EditorLineHeight()*effectiveFontScale density-dp. Every piece of
// geometry bookkeeping (window start, sub-line remainder, tap mapping,
// scroll clamping, cursor vertical move) must use this value rather than the
// raw EditorLineHeight; at a non-default font setting the two differ by the
// font scale, which would misplace taps by up to (fontScale-1) viewportfuls
// of lines and make scroll clamping stop short of (or run past) the file
// ends.
func EffectiveLineHeight() ui.Dp {
return EffectiveLineHeightAt(stateFontScale())
return EffectiveLineHeightAt(effectiveFontScale())
}
// effectiveFontScale is the TOTAL font scale of the rendered line pitch:
// the system user font scale times the app-local pinch scale. The system
// part is already folded into gtx.Metric on the render side; the logic side
// needs the product for its dp bookkeeping.
func effectiveFontScale() float32 {
fs := stateFontScale()
as := float32(1)
if TheState != nil && TheState.appFontScale > 0 {
as = TheState.appFontScale
}
return fs * as
}
// rescaleScrollAnchored scales the scroll offset by ratio while keeping the
// content point under app-local Y anchorY fixed on screen. The document is
// uniformly scaled by the font change (every line height and the sub-line
// offset scale by the same factor), so the content coordinate under the
// anchor scales by ratio; the new offset re-places that scaled coordinate
// under the same app point. With anchorY at the region top this degenerates
// to the plain top-anchor (new = old * ratio). A zero EditorRegion (not
// laid out yet) likewise degenerates to the top anchor.
func (s *State) rescaleScrollAnchored(ratio float64, anchorY float64) {
regionTop := float64(s.EditorRegion.Y)
dy := anchorY - regionTop
contentY := float64(s.ScrollOffset) + dy
s.ScrollOffset = ui.Dp(contentY*ratio - dy)
}
// glyphAtLocalPoint returns the index of the glyph a window-frame point
// (x, y in Dp; y relative to the window top, the same frame as GlyphLayout.Y)
// sits on: the display line identified from y, and on that line the last
// glyph whose X is at or before x. ok=false when the layout is empty, the
// line has no glyph, or x is in the left margin before the line's first
// glyph. (A point past the line's END still pins that line's last glyph:
// the content there is the line itself.)
func glyphAtLocalPoint(gl ui.GlyphLayout, x, y float64) (int, bool) {
lh := float64(gl.LineHeight)
if lh <= 0 || len(gl.ByteOffsets) == 0 {
return 0, false
}
line := int(y / lh)
if line < 0 {
line = 0
}
// The baseline of display line j sits in (j*lh, (j+1)*lh]; all glyphs on
// a line share one exact shaper value, so a range test finds the line's
// baseline.
base := -1.0
for _, gy := range gl.Y {
if f := float64(gy); f > float64(line)*lh && f <= float64(line+1)*lh {
base = f
break
}
}
if base < 0 {
return 0, false
}
best := -1
for i, gy := range gl.Y {
if float64(gy) != base {
continue
}
if float64(gl.X[i]) <= x+1e-9 {
best = i
}
}
return best, best >= 0
}
// contentPin is the anchor a pinch holds: a CONTENT point, not a layout
// point. Byte/Dy name the glyph (ABSOLUTE buffer byte) under the fingers and
// the point's offset from that glyph's baseline — both invariant under
// rewrap, where a visual line is not (a rewrapped fragment holds different
// text at the same fragment index). Line/Frag/Sub is the (logical line,
// fragment, sub-line) fallback anchor for frames without a shaped glyph
// under the point. EditSeq invalidates the pin on edits.
type contentPin struct {
Byte int
Dy float64
Line int
Frag int
Sub float64
HaveGlyph bool
EditSeq uint64
}
// invalidateShapedLayout drops the last shaped GlyphLayout. Call it whenever
// the SHAPING INPUTS change outside an edit (a font-scale change): the old
// layout's LineHeight/X/Y belong to the old size, and every consumer that
// falls back on it (window-start line, max scroll, tap mapping) would run
// the new scroll offset through the OLD line height for a frame or two —
// enough to put the shaped window ten thousand lines from the viewport.
// The next frame re-shapes and the feedback refills it; until then the
// logic-side geometry uses EffectiveLineHeight(), which tracks the scale.
func (s *State) invalidateShapedLayout() {
s.Editor.GlyphLayout = ui.GlyphLayout{}
}
// captureContentPin identifies the content point at region-relative (x, y)
// (dp from the editor region's left/top): the glyph under the point and the
// point's offset from its baseline, plus the (line, fragment, sub-line)
// fallback anchor. Must be called BEFORE the font change it will anchor.
func (s *State) captureContentPin(x, y float64) contentPin {
pin := contentPin{Byte: -1}
// Window-frame y (the GlyphLayout frame): the point's region-relative y
// plus the sub-line draw offset, the same convention as tapLocalY.
_, r := scrollVisualDecompose()
localY := y + r
gl := s.Editor.GlyphLayout
if i, ok := glyphAtLocalPoint(gl, x, localY); ok {
// ByteOffsets are window-relative; IMEWindowStartByte is the absolute
// byte of the window's first byte (the same value the Frame ships as
// WindowStartByte).
pin.Byte = s.Editor.IMEWindowStartByte + gl.ByteOffsets[i]
pin.Dy = localY - float64(gl.Y[i])
pin.HaveGlyph = true
}
if line, frag, sub, ok := s.captureFontPin(y); ok {
pin.Line, pin.Frag, pin.Sub = line, frag, sub
}
pin.EditSeq = s.Editor.EditSeq
return pin
}
// captureFontPin identifies the fallback layout anchor at region-relative Y
// m (dp from the top of the editor text region): the LOGICAL line under the
// point (through the current WrapIndex), the display line (wrap fragment) of
// that line the point is on, and the sub-line fraction within that fragment.
func (s *State) captureFontPin(m float64) (line, frag int, sub float64, ok bool) {
lh := float64(EffectiveLineHeight())
if lh <= 0 {
return 0, 0, 0, false
}
u := (float64(s.ScrollOffset) + m) / lh // continuous display-line coordinate under the point
k := int(u)
if k < 0 {
k = 0
}
sub = u - float64(k)
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && k < cb.WrapIndex.Len() {
l := cb.WrapIndex.LineForVisual(int32(k))
base := int(cb.WrapIndex.VisualsBefore(l))
frag = k - base
if frag < 0 {
frag = 0
}
return l, frag, sub, true
}
return k, 0, sub, true // no wrap index: display line == logical line
}
// refineContentPin computes the scroll offset that places the pinned CONTENT
// point — absolute buffer byte byteOff, dy below its baseline — at
// region-relative Y m, given a freshly shaped layout (gl) for the window
// starting at windowStartByte (both from the same LayoutFeedback). The window
// top (layout y=0) sits at the top of the window's FIRST visual line, whose
// content coordinate is VisualsBefore(windowStartLine)*lh — the vk argument
// (NOT floor(shapedScroll/lh), which is a different line whenever the
// viewport top lands mid-way through a wrapped logical line) — so the glyph's
// content coordinate is vk*lh + Y + dy, and setting the offset to that minus
// m puts the point on the center exactly. This is what keeps the CHARACTER
// under the fingers when a rewrap moves it to a different fragment: the byte
// is invariant, its Y is read from the fresh layout. ok=false when the layout
// is unusable or the byte is not in the shaped window (then the caller falls
// back to the (line, frag, sub) anchor).
func refineContentPin(gl ui.GlyphLayout, vk, windowStartByte int, dy, m float64, byteOff int) (ui.Dp, bool) {
lh := gl.LineHeight
if lh <= 0 || len(gl.ByteOffsets) == 0 || byteOff < 0 || vk < 0 {
return 0, false
}
i := sort.Search(len(gl.ByteOffsets), func(i int) bool { return windowStartByte+gl.ByteOffsets[i] >= byteOff })
if i >= len(gl.ByteOffsets) || windowStartByte+gl.ByteOffsets[i] != byteOff {
return 0, false
}
contentY := float64(vk)*float64(lh) + float64(gl.Y[i]) + dy
return ui.Dp(contentY - m), true
}
// applyFontPin sets the scroll offset so the fallback anchor (logical line L,
// its frag-th display line, sub-line fraction sub within it) sits at
// region-relative Y m, under the CURRENT WrapIndex and line height. This is
// the pin's stand-in for frames without a shaped glyph under the point; it is
// re-runnable as wrap-count corrections land (the same mechanism as the
// restore line-pin, aimed at a point mid-viewport). No MaxScroll clamp here:
// the layout pass of the emitted frame clamps to the fresh value (a stale
// MaxScroll would under-clamp).
func (s *State) applyFontPin(line, frag int, sub, m float64) {
lh := float64(EffectiveLineHeight())
if lh <= 0 {
return
}
base := float64(line)
if cb := s.Editor.ChunkedBuffer; cb != nil && cb.WrapIndex != nil && line >= 0 && line < cb.WrapIndex.Len() {
base = float64(cb.WrapIndex.VisualsBefore(line))
// The line's fragment count may have shrunk (pinch out): keep the
// pinned fragment inside the line's new fragment range.
if line+1 < cb.WrapIndex.Len() {
count := int(cb.WrapIndex.VisualsBefore(line+1) - cb.WrapIndex.VisualsBefore(line))
if count > 0 && frag >= count {
frag = count - 1
}
}
}
s.ScrollOffset = ui.Dp((base+float64(frag)+sub)*lh - m)
}
// HandleFontPinch applies one frame's relative two-finger pinch factor to
// the app-local font scale (ui.FontPinchEvent, delivered by the renderer's
// pinch probe). The scale is a continuous float — multiplied by the
// per-frame distance ratio, clamped to [MinAppFontScale, MaxAppFontScale],
// never rounded — so the text size tracks the fingers smoothly. The content
// under the pinch CENTER (event's Center point) stays anchored: the Logic
// pins the logical line + fragment + sub-line under the center and
// re-derives the scroll offset under it as the font — and, a few frames
// later, the rewrap counts — change (see Logic.applyFontPinch).
func HandleFontPinch(data any) {
f, ok := data.(ui.FontPinchEvent)
log.Printf("PINCH logic HandleFontPinch scale=%.4f center=(%.0f,%.0f) ok=%v", f.Scale, f.Center.X, f.Center.Y, ok)
if !ok || f.Scale <= 0 {
return
}
s := TheState
// Region-relative (x, y) of the pinch center. Capture the CONTENT point
// the fingers are on (the glyph under the point + offset from its
// baseline) under the PRE-change layout; the scale change, then the
// re-derivation, keep that same content point under the center.
x := float64(f.Center.X - s.EditorRegion.X)
m := float64(f.Center.Y - s.EditorRegion.Y)
pin := s.captureContentPin(x, m)
old := s.appFontScale
if old <= 0 {
old = 1
}
ns := old * f.Scale
if ns < MinAppFontScale {
ns = MinAppFontScale
}
if ns > MaxAppFontScale {
ns = MaxAppFontScale
}
if ns == s.appFontScale {
return // no change: nothing to re-derive
}
s.appFontScale = ns
// The last shaped layout belongs to the old size (see
// invalidateShapedLayout): without this, the next frame computes its
// window start with the OLD line height and the new (rescaled) offset —
// a window ten thousand lines from the viewport — and the pin chases it.
s.invalidateShapedLayout()
ratio := float64(ns) / float64(old)
if TheLogic != nil {
TheLogic.releaseRestorePin() // the user takes over the viewport
TheLogic.setFontPin(pin, m, ratio) // re-derives the offset under it
} else {
// Test harness without a Logic: the immediate continuous anchor.
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y)+m)
}
}
// SetAppFontScale sets the app-local font scale to an absolute value
// (clamped to [MinAppFontScale, MaxAppFontScale]) with the VIEWPORT TOP as
// the anchor (no fingers are involved). Used by the one-shot debug command.
func SetAppFontScale(v float32) {
s := TheState
old := s.appFontScale
if old <= 0 {
old = 1
}
if v < MinAppFontScale {
v = MinAppFontScale
}
if v > MaxAppFontScale {
v = MaxAppFontScale
}
if v == s.appFontScale {
return // no change
}
s.appFontScale = v
// Same stale-layout hazard as HandleFontPinch: the window start for the
// next frame must be computed with the NEW line height.
s.invalidateShapedLayout()
ratio := float64(s.appFontScale) / float64(old)
s.rescaleScrollAnchored(ratio, float64(s.EditorRegion.Y))
if TheLogic != nil {
TheLogic.releaseRestorePin()
TheLogic.releaseFontPin()
}
}
// EffectiveLineHeightAt is EffectiveLineHeight for an explicit font scale
@ -345,6 +650,10 @@ func HandleScroll(data any) {
return
}
delta := data.(int) // pixels
if TheLogic != nil {
TheLogic.releaseRestorePin() // the user takes over the viewport
TheLogic.releaseFontPin()
}
TheState.ScrollOffset += ui.ToDp(ui.Px(delta), TheState.scale)
if TheState.ScrollOffset < 0 {
TheState.ScrollOffset = 0
@ -2242,6 +2551,9 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
}
}},
{Gesture: ui.SelDrag, Handler: HandleSelDragEvt},
// Two-finger pinch changes the app-local font scale continuously
// (the renderer owns the probe; see ui.Pinch).
{Gesture: ui.Pinch, Handler: HandleFontPinch},
},
)
// While the find bar is open, key focus belongs to the main-owned

View File

@ -202,4 +202,3 @@ func byteOffsetOfLine(content string, i int) int {
}
return o
}

View File

@ -53,13 +53,13 @@ func (t *cancelTask) Execute() Result {
}
}
func (t *cancelTask) Priority() Priority { return t.priority }
func (t *cancelTask) TaskID() string { return t.id }
func (t *cancelTask) TaskType() TaskType { return t.taskType }
func (t *cancelTask) DirPath() string { return "/test" }
func (t *cancelTask) Priority() Priority { return t.priority }
func (t *cancelTask) TaskID() string { return t.id }
func (t *cancelTask) TaskType() TaskType { return t.taskType }
func (t *cancelTask) DirPath() string { return "/test" }
func (t *cancelTask) Context() context.Context { return t.ctx }
func (t *cancelTask) Cancel() { t.cancel() }
func (t *cancelTask) Timeout() time.Duration { return 0 }
func (t *cancelTask) Cancel() { t.cancel() }
func (t *cancelTask) Timeout() time.Duration { return 0 }
// timeoutTask is a task that respects timeout.
type timeoutTask struct {
@ -79,13 +79,13 @@ func (t *timeoutTask) Execute() Result {
}
}
func (t *timeoutTask) Priority() Priority { return t.priority }
func (t *timeoutTask) TaskID() string { return t.id }
func (t *timeoutTask) TaskType() TaskType { return t.taskType }
func (t *timeoutTask) DirPath() string { return "/test" }
func (t *timeoutTask) Priority() Priority { return t.priority }
func (t *timeoutTask) TaskID() string { return t.id }
func (t *timeoutTask) TaskType() TaskType { return t.taskType }
func (t *timeoutTask) DirPath() string { return "/test" }
func (t *timeoutTask) Context() context.Context { return context.Background() }
func (t *timeoutTask) Cancel() {}
func (t *timeoutTask) Timeout() time.Duration { return 50 * time.Millisecond }
func (t *timeoutTask) Cancel() {}
func (t *timeoutTask) Timeout() time.Duration { return 50 * time.Millisecond }
func TestTaskContextCancellation(t *testing.T) {
task := newCancelTask("test-cancel", 1*time.Second)

View File

@ -68,10 +68,10 @@ type notifyEvent struct {
// FileSystem is a thread-safe in-memory filesystem for testing.
type FileSystem struct {
mu sync.RWMutex
files map[string]*File // path -> file
delay time.Duration // uniform delay applied to all operations
changes chan FileChangeEvent // async change notifications (nil = no notifications)
writeError bool // Added to simulate write errors
files map[string]*File // path -> file
delay time.Duration // uniform delay applied to all operations
changes chan FileChangeEvent // async change notifications (nil = no notifications)
writeError bool // Added to simulate write errors
}
// NewFileSystem creates an empty mock filesystem.

View File

@ -115,8 +115,6 @@ func (t TaskType) String() string {
}
}
// --- Specific Task Implementations ---
// ReadChunkTask reads a specific chunk of a file.
@ -163,13 +161,17 @@ func (t *ReadChunkTask) Execute() Result {
}
}
func (t *ReadChunkTask) Priority() Priority { return HighPriority }
func (t *ReadChunkTask) TaskType() TaskType { return TypeReadChunk }
func (t *ReadChunkTask) TaskID() string { return t.taskID }
func (t *ReadChunkTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadChunkTask) Priority() Priority { return HighPriority }
func (t *ReadChunkTask) TaskType() TaskType { return TypeReadChunk }
func (t *ReadChunkTask) TaskID() string { return t.taskID }
func (t *ReadChunkTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadChunkTask) Context() context.Context { return t.ctx }
func (t *ReadChunkTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadChunkTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *ReadChunkTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadChunkTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// StatFileTask retrieves file metadata (like size and modification time).
// The plan indicates this replaces the full-file ReadFileTask for opening.
@ -210,13 +212,17 @@ func (t *StatFileTask) Execute() Result {
}
}
func (t *StatFileTask) Priority() Priority { return MediumPriority }
func (t *StatFileTask) TaskType() TaskType { return TypeStatFile }
func (t *StatFileTask) TaskID() string { return t.taskID }
func (t *StatFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *StatFileTask) Priority() Priority { return MediumPriority }
func (t *StatFileTask) TaskType() TaskType { return TypeStatFile }
func (t *StatFileTask) TaskID() string { return t.taskID }
func (t *StatFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *StatFileTask) Context() context.Context { return t.ctx }
func (t *StatFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *StatFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *StatFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *StatFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// FileStat holds file metadata.
type FileStat struct {
@ -267,13 +273,17 @@ func (t *BuildLineIndexTask) Execute() Result {
}
}
func (t *BuildLineIndexTask) Priority() Priority { return LowPriority }
func (t *BuildLineIndexTask) TaskType() TaskType { return TypeBuildLineIndex }
func (t *BuildLineIndexTask) TaskID() string { return t.taskID }
func (t *BuildLineIndexTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *BuildLineIndexTask) Priority() Priority { return LowPriority }
func (t *BuildLineIndexTask) TaskType() TaskType { return TypeBuildLineIndex }
func (t *BuildLineIndexTask) TaskID() string { return t.taskID }
func (t *BuildLineIndexTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *BuildLineIndexTask) Context() context.Context { return t.ctx }
func (t *BuildLineIndexTask) Timeout() time.Duration { return 30 * time.Second }
func (t *BuildLineIndexTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *BuildLineIndexTask) Timeout() time.Duration { return 30 * time.Second }
func (t *BuildLineIndexTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// BuildIndexTask builds the directory index for the browser.
type BuildIndexTask struct {
@ -304,13 +314,17 @@ func (t *BuildIndexTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeBuildIndex, Success: true, Data: entries}
}
func (t *BuildIndexTask) Priority() Priority { return HighPriority }
func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex }
func (t *BuildIndexTask) TaskID() string { return t.taskID }
func (t *BuildIndexTask) DirPath() string { return t.Dir }
func (t *BuildIndexTask) Priority() Priority { return HighPriority }
func (t *BuildIndexTask) TaskType() TaskType { return TypeBuildIndex }
func (t *BuildIndexTask) TaskID() string { return t.taskID }
func (t *BuildIndexTask) DirPath() string { return t.Dir }
func (t *BuildIndexTask) Context() context.Context { return t.ctx }
func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second }
func (t *BuildIndexTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *BuildIndexTask) Timeout() time.Duration { return 5 * time.Second }
func (t *BuildIndexTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// LoadPagesTask loads specific pages from a directory index.
type LoadPagesTask struct {
@ -343,13 +357,17 @@ func (t *LoadPagesTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeLoadPages, Success: true, Data: t.PageIdxs}
}
func (t *LoadPagesTask) Priority() Priority { return HighPriority }
func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages }
func (t *LoadPagesTask) TaskID() string { return t.taskID }
func (t *LoadPagesTask) DirPath() string { return t.Dir }
func (t *LoadPagesTask) Priority() Priority { return HighPriority }
func (t *LoadPagesTask) TaskType() TaskType { return TypeLoadPages }
func (t *LoadPagesTask) TaskID() string { return t.taskID }
func (t *LoadPagesTask) DirPath() string { return t.Dir }
func (t *LoadPagesTask) Context() context.Context { return t.ctx }
func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second }
func (t *LoadPagesTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *LoadPagesTask) Timeout() time.Duration { return 5 * time.Second }
func (t *LoadPagesTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// ReadDirTask reads directory entries.
type ReadDirTask struct {
@ -381,13 +399,17 @@ func (t *ReadDirTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeReadDir, Success: true, Data: entries}
}
func (t *ReadDirTask) Priority() Priority { return HighPriority }
func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir }
func (t *ReadDirTask) TaskID() string { return t.taskID }
func (t *ReadDirTask) DirPath() string { return t.Dir }
func (t *ReadDirTask) Priority() Priority { return HighPriority }
func (t *ReadDirTask) TaskType() TaskType { return TypeReadDir }
func (t *ReadDirTask) TaskID() string { return t.taskID }
func (t *ReadDirTask) DirPath() string { return t.Dir }
func (t *ReadDirTask) Context() context.Context { return t.ctx }
func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadDirTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *ReadDirTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadDirTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SaveStateTask persists application state.
type SaveStateTask struct {
@ -421,13 +443,17 @@ func (t *SaveStateTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeSaveState, Success: true}
}
func (t *SaveStateTask) Priority() Priority { return LowPriority }
func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState }
func (t *SaveStateTask) TaskID() string { return t.taskID }
func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveStateTask) Priority() Priority { return LowPriority }
func (t *SaveStateTask) TaskType() TaskType { return TypeSaveState }
func (t *SaveStateTask) TaskID() string { return t.taskID }
func (t *SaveStateTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveStateTask) Context() context.Context { return t.ctx }
func (t *SaveStateTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveStateTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *SaveStateTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveStateTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SaveUndoTask persists undo stack.
type SaveUndoTask struct {
@ -461,13 +487,17 @@ func (t *SaveUndoTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeSaveUndo, Success: true}
}
func (t *SaveUndoTask) Priority() Priority { return LowPriority }
func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo }
func (t *SaveUndoTask) TaskID() string { return t.taskID }
func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveUndoTask) Priority() Priority { return LowPriority }
func (t *SaveUndoTask) TaskType() TaskType { return TypeSaveUndo }
func (t *SaveUndoTask) TaskID() string { return t.taskID }
func (t *SaveUndoTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *SaveUndoTask) Context() context.Context { return t.ctx }
func (t *SaveUndoTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveUndoTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *SaveUndoTask) Timeout() time.Duration { return 5 * time.Second }
func (t *SaveUndoTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// ReadFileTask reads the full content of a file.
// Used as a fallback for small files or initial load before chunking is set up.
@ -499,13 +529,17 @@ func (t *ReadFileTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeReadFile, FilePath: t.Path, Success: true, Data: content}
}
func (t *ReadFileTask) Priority() Priority { return HighPriority }
func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile }
func (t *ReadFileTask) TaskID() string { return t.taskID }
func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadFileTask) Priority() Priority { return HighPriority }
func (t *ReadFileTask) TaskType() TaskType { return TypeReadFile }
func (t *ReadFileTask) TaskID() string { return t.taskID }
func (t *ReadFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *ReadFileTask) Context() context.Context { return t.ctx }
func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *ReadFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *ReadFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// SearchData is the payload of a completed SearchTask: the byte ranges
// [start, end) of every match of Query in the scanned Text, ascending and
@ -587,13 +621,17 @@ func toLowerFold(s string) string {
// unit; it is the standard library's two-way search.
func indexOf(s, sub string) int { return strings.Index(s, sub) }
func (t *SearchTask) Priority() Priority { return MediumPriority }
func (t *SearchTask) TaskType() TaskType { return TypeSearch }
func (t *SearchTask) TaskID() string { return t.taskID }
func (t *SearchTask) DirPath() string { return "" }
func (t *SearchTask) Context() context.Context { return t.ctx }
func (t *SearchTask) Timeout() time.Duration { return 10 * time.Second }
func (t *SearchTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *SearchTask) Priority() Priority { return MediumPriority }
func (t *SearchTask) TaskType() TaskType { return TypeSearch }
func (t *SearchTask) TaskID() string { return t.taskID }
func (t *SearchTask) DirPath() string { return "" }
func (t *SearchTask) Context() context.Context { return t.ctx }
func (t *SearchTask) Timeout() time.Duration { return 10 * time.Second }
func (t *SearchTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// WriteFileTask writes content to a file.
type WriteFileTask struct {
@ -626,13 +664,17 @@ func (t *WriteFileTask) Execute() Result {
return Result{TaskID: t.taskID, TaskType: TypeWriteFile, FilePath: t.Path, Success: true}
}
func (t *WriteFileTask) Priority() Priority { return LowPriority }
func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile }
func (t *WriteFileTask) TaskID() string { return t.taskID }
func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *WriteFileTask) Priority() Priority { return LowPriority }
func (t *WriteFileTask) TaskType() TaskType { return TypeWriteFile }
func (t *WriteFileTask) TaskID() string { return t.taskID }
func (t *WriteFileTask) DirPath() string { return filepath.Dir(t.Path) }
func (t *WriteFileTask) Context() context.Context { return t.ctx }
func (t *WriteFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *WriteFileTask) Cancel() { if t.cancel != nil { t.cancel() } }
func (t *WriteFileTask) Timeout() time.Duration { return 5 * time.Second }
func (t *WriteFileTask) Cancel() {
if t.cancel != nil {
t.cancel()
}
}
// --- Mock File System (for testing/development) ---
// MockFS implements the pool.FileSystem interface for testing.
@ -709,12 +751,12 @@ type mockFileInfo struct {
modTime time.Time
}
func (f *mockFileInfo) Name() string { return f.name }
func (f *mockFileInfo) Size() int64 { return f.size }
func (f *mockFileInfo) Mode() os.FileMode { return os.FileMode(f.mode) }
func (f *mockFileInfo) ModTime() time.Time { return f.modTime }
func (f *mockFileInfo) IsDir() bool { return false }
func (f *mockFileInfo) Sys() any { return nil }
func (f *mockFileInfo) Name() string { return f.name }
func (f *mockFileInfo) Size() int64 { return f.size }
func (f *mockFileInfo) Mode() os.FileMode { return os.FileMode(f.mode) }
func (f *mockFileInfo) ModTime() time.Time { return f.modTime }
func (f *mockFileInfo) IsDir() bool { return false }
func (f *mockFileInfo) Sys() any { return nil }
// mockDirEntry implements types.DirEntry for MockFS ReadDir.
type mockDirEntry struct {
@ -722,8 +764,8 @@ type mockDirEntry struct {
isDir bool
}
func (e *mockDirEntry) Name() string { return e.name }
func (e *mockDirEntry) IsDir() bool { return e.isDir }
func (e *mockDirEntry) Name() string { return e.name }
func (e *mockDirEntry) IsDir() bool { return e.isDir }
func (e *mockDirEntry) Info() (os.FileInfo, error) {
return &mockFileInfo{name: e.name, size: 0}, nil
}
@ -739,6 +781,3 @@ func (m *MockFS) ReadDir(path string) ([]types.DirEntry, error) {
}
var _ FileSystem = (*MockFS)(nil) // Compile-time interface check

View File

@ -68,25 +68,25 @@ func (li *LineIndex) FindLogicalLineForByteOffset(byteOffset int) int {
if len(li.Offsets) == 0 {
return -1
}
// Binary search to find the line that contains this byte offset
low, high := 0, len(li.Offsets)-1
for low <= high {
mid := (low + high) / 2
midOffset := int(li.Offsets[mid])
if byteOffset >= midOffset {
// This line starts at or before our byte offset
// Check if the next line starts after our byte offset
if mid == len(li.Offsets)-1 || int(li.Offsets[mid+1]) > byteOffset {
return mid // Found the line
}
low = mid + 1
} else {
high = mid - 1
midOffset := int(li.Offsets[mid])
if byteOffset >= midOffset {
// This line starts at or before our byte offset
// Check if the next line starts after our byte offset
if mid == len(li.Offsets)-1 || int(li.Offsets[mid+1]) > byteOffset {
return mid // Found the line
}
low = mid + 1
} else {
high = mid - 1
}
}
return -1 // Not found
}

View File

@ -13,15 +13,15 @@ import (
// - Workers execute tasks and post results on resultChan
// - Workers never access state directly
type WorkerPool struct {
highWorkChan chan Task
lowWorkChan chan Task
resultChan chan Result
workerCount int
stopOnce sync.Once
stopChan chan struct{}
wg sync.WaitGroup
taskCounter atomic.Int64
running atomic.Bool
highWorkChan chan Task
lowWorkChan chan Task
resultChan chan Result
workerCount int
stopOnce sync.Once
stopChan chan struct{}
wg sync.WaitGroup
taskCounter atomic.Int64
running atomic.Bool
// pendingTasks tracks in-flight tasks for cancellation by directory.
pendingTasks map[string][]Task

View File

@ -42,13 +42,13 @@ func (t *stubTask) Execute() Result {
}
}
func (t *stubTask) Priority() Priority { return t.priority }
func (t *stubTask) TaskID() string { return t.id }
func (t *stubTask) TaskType() TaskType { return t.taskType }
func (t *stubTask) DirPath() string { return "" }
func (t *stubTask) Priority() Priority { return t.priority }
func (t *stubTask) TaskID() string { return t.id }
func (t *stubTask) TaskType() TaskType { return t.taskType }
func (t *stubTask) DirPath() string { return "" }
func (t *stubTask) Context() context.Context { return context.Background() }
func (t *stubTask) Cancel() {}
func (t *stubTask) Timeout() time.Duration { return 5 * time.Second }
func (t *stubTask) Cancel() {}
func (t *stubTask) Timeout() time.Duration { return 5 * time.Second }
func TestWorkerPool_StartStop(t *testing.T) {
pool := NewWorkerPool(4)
@ -672,9 +672,9 @@ func TestWorkerPool_ResultIsSuccess(t *testing.T) {
}
result2 := Result{
TaskID: "test",
TaskID: "test",
Success: false,
Error: fmt.Errorf("error"),
Error: fmt.Errorf("error"),
}
if result2.IsSuccess() {
t.Error("Result should not be successful")
@ -683,7 +683,7 @@ func TestWorkerPool_ResultIsSuccess(t *testing.T) {
func TestWorkerPool_ResultIsError(t *testing.T) {
result := Result{
TaskID: "test",
TaskID: "test",
Success: false,
Error: fmt.Errorf("error"),
}

View File

@ -20,7 +20,7 @@ func TestEditorInitialLayout(t *testing.T) {
t.Fatalf("GoToEditor: %v", err)
}
h.SendConfig(780, 1688)
// Wait for a new frame after switching to editor page
_, err := e2e.WaitForNewFrame(h, h.FrameCount(), 5*time.Second)
if err != nil {

View File

@ -14,7 +14,7 @@ type TestScenario struct {
Setup func(*Harness)
Actions []HarnessAction
Assertions []FrameAssertion
FrameIndex int // which frame to assert on (-1 for last)
FrameIndex int // which frame to assert on (-1 for last)
Timeout time.Duration
}

View File

@ -0,0 +1,835 @@
package e2e_test
// Relaunch state restoration (spec §7): the last file, cursor, scroll,
// selection and find bar state are persisted as a tiny JSON snapshot and
// restored on the next launch. These tests drive the same entry points the
// cmd layer uses: Logic.BeginRestore before Run (restore) and the session
// saver callback (persist).
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"pad/internal/editor"
"pad/internal/io/pool/real"
"pad/internal/test/e2e"
"pad/internal/ui"
)
// restoreHarness writes content to <tempdir>/name, builds a harness over the
// real filesystem, hands sess to the logic BEFORE Run (as the cmd layer does
// on relaunch), starts it with the standard 390x844@2x window, and waits for
// the restored file to fully load (content + line index).
func restoreHarness(t *testing.T, name, content string, sess editor.SessionState) *e2e.Harness {
t.Helper()
dir := t.TempDir()
diskPath := filepath.Join(dir, name)
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
sess.File = "/" + name
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(sess)
h.Run()
h.SendConfig(780, 1688) // 390x844 @ 2x scale
h.SendScale(2.0)
// "Loaded" means both async results are fully applied: the ReadFile
// content AND the BuildLineIndex result (same contract as
// realFileHarness: FileLen>0 && LineIndex!=nil alone is a race).
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
full, err := cb.FullContent()
return err == nil && int64(len(full)) == cb.FileLen() &&
cb.LineIndex.Size == cb.FileLen()
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return h
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timed out waiting for restored file to load")
return nil
}
// TestRestore_FileCursorScrollSelection restores a file with a cursor, a
// non-zero scroll and a live selection; all three must land once the content
// arrives, on the editor page (not the browser).
func TestRestore_FileCursorScrollSelection(t *testing.T) {
content := strings.Repeat("hello world line\n", 100)
h := restoreHarness(t, "notes.txt", content, editor.SessionState{
Cursor: 123,
Scroll: 100,
SelStart: 100,
SelEnd: 110,
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
}{st.Page(), st.Editor.Filename, st.Editor.CursorPosition, st.ScrollOffset,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.Editor.SelectionAnchor}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Page editor.Page
File string
Cursor int
Scroll ui.Dp
SelStart int
SelEnd int
Anchor int
})
if got.Page != editor.EditorPage {
t.Errorf("page = %v, want EditorPage (restore lands straight in the editor)", got.Page)
}
if got.File != "/notes.txt" {
t.Errorf("file = %q, want /notes.txt", got.File)
}
if got.Cursor != 123 {
t.Errorf("cursor = %d, want 123", got.Cursor)
}
if got.Scroll != ui.Dp(100) {
t.Errorf("scroll = %v, want 100", got.Scroll)
}
if got.SelStart != 100 || got.SelEnd != 110 || got.Anchor != 100 {
t.Errorf("selection = [%d,%d) anchor %d, want [100,110) anchor 100",
got.SelStart, got.SelEnd, got.Anchor)
}
}
// TestRestore_ScrollLandsWhenScalePrecedesSize reproduces the on-device
// startup ordering, where the first FrameEvent (and hence the ScaleEvent)
// arrives before the size ConfigEvent. The scroll must still land, clamped
// by the layout at the real scale: an earlier revision applied it at the
// ScaleEvent against a not-yet-sized viewport and lost it.
func TestRestore_ScrollLandsWhenScalePrecedesSize(t *testing.T) {
content := strings.Repeat("hello world line\n", 100)
dir := t.TempDir()
diskPath := filepath.Join(dir, "late.txt")
if err := os.WriteFile(diskPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(editor.SessionState{File: "/late.txt", Cursor: 123, Scroll: 100})
h.Run()
h.SendScale(2.0) // scale first
h.SendConfig(780, 1688) // size second (on-device ordering)
defer h.Cleanup()
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
full, err := cb.FullContent()
return err == nil && int64(len(full)) == cb.FileLen() &&
cb.LineIndex.Size == cb.FileLen() && st.ScrollOffset == ui.Dp(100)
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatal("timed out: scroll did not land when the scale event preceded the size")
}
// TestRestore_ClampsToShorterFile: the file was truncated since the last
// session; the cursor and selection must clamp to the shorter content, not
// restore past EOF.
func TestRestore_ClampsToShorterFile(t *testing.T) {
content := "0123456789" // 10 bytes
h := restoreHarness(t, "short.txt", content, editor.SessionState{
Cursor: 57, // far past EOF
SelStart: 5,
SelEnd: 99, // spans EOF
})
defer h.Cleanup()
v, err := h.Inspect(func(st *editor.State) any {
return struct{ Cursor, SelStart, SelEnd int }{
st.Editor.CursorPosition, st.Editor.SelectionStart, st.Editor.SelectionEnd,
}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct{ Cursor, SelStart, SelEnd int })
if got.Cursor != 10 {
t.Errorf("cursor = %d, want 10 (clamped to EOF)", got.Cursor)
}
if got.SelStart != 5 || got.SelEnd != 10 {
t.Errorf("selection = [%d,%d), want [5,10) (end clamped to EOF)", got.SelStart, got.SelEnd)
}
}
// TestRestore_MissingFileFallsBackToBrowser: the restored file was deleted
// since the last session; the app must land on the browser page with a clean
// editor, not an empty editor page.
func TestRestore_MissingFileFallsBackToBrowser(t *testing.T) {
dir := t.TempDir()
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(editor.SessionState{File: "/gone.txt", Cursor: 10})
h.Run()
defer h.Cleanup()
h.SendConfig(780, 1688)
h.SendScale(2.0)
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
return st.Page() == editor.BrowserPage && st.Editor.Filename == "" &&
st.Editor.ChunkedBuffer == nil
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if v.(bool) {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: restore of a missing file did not fall back to the browser")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBar reopens the file with the find bar open, a query and a
// current match: the query must re-scan, select the saved current match (by
// byte offset) and NOT re-scroll the restored viewport.
func TestRestore_FindBar(t *testing.T) {
var b strings.Builder
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "filler line number %02d\n", i)
}
b.WriteString("needle at start of the interesting part\n")
b.WriteString("middle filler\n")
b.WriteString("needle in the middle of it\n")
b.WriteString("tail filler\n")
b.WriteString("needle at the end\n")
for i := 0; i < 50; i++ {
fmt.Fprintf(&b, "trailing filler %02d\n", i)
}
content := b.String()
// Offsets of the three "needle" occurrences.
first := strings.Index(content, "needle")
second := strings.Index(content[first+1:], "needle") + first + 1
third := strings.Index(content[second+1:], "needle") + second + 1
if first < 0 || second < 0 || third < 0 {
t.Fatal("test content lost its needles")
}
h := restoreHarness(t, "findme.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: true,
FindCurByte: second,
// Cursor/scroll restored at the top: if the re-scan wrongly
// re-scrolls to the match, ScrollOffset would no longer be 0.
})
defer h.Cleanup()
deadline := time.Now().Add(5 * time.Second)
for {
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
}{f.Visible, f.Scanning, len(f.Matches), f.Cur,
st.Editor.SelectionStart, st.Editor.SelectionEnd, st.ScrollOffset}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
N, Cur, SelStart, SelEnd int
Scroll ui.Dp
})
if !got.Scanning && got.N == 3 {
if !got.Visible {
t.Errorf("find bar visible = false, want true")
}
if got.Cur != 1 {
t.Errorf("current match = %d, want 1 (the saved current item)", got.Cur)
}
if got.SelStart != second || got.SelEnd != second+len("needle") {
t.Errorf("selection = [%d,%d), want [%d,%d)",
got.SelStart, got.SelEnd, second, second+len("needle"))
}
if got.Scroll != 0 {
t.Errorf("scroll = %v, want 0 (restore must not re-scroll to the match)", got.Scroll)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for the restore re-scan (matches=%d scanning=%v)", got.N, got.Scanning)
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_FindBarClosed keeps the query but does not open the bar: no
// scan may run at restore (it would be dropped and leave Scanning stuck);
// opening the bar afterwards must re-scan and produce the matches.
func TestRestore_FindBarClosed(t *testing.T) {
var b strings.Builder
for i := 0; i < 60; i++ {
fmt.Fprintf(&b, "padding line %02d\n", i)
}
b.WriteString("needle one\nneedle two\nneedle three\n")
content := b.String()
h := restoreHarness(t, "closed.txt", content, editor.SessionState{
FindQuery: "needle",
FindVisible: false,
})
defer h.Cleanup()
// Give any (wrong) eager scan time to dispatch, then check state.
time.Sleep(300 * time.Millisecond)
v, err := h.Inspect(func(st *editor.State) any {
f := &st.Editor.Find
return struct {
Visible, Scanning bool
Query string
N int
}{f.Visible, f.Scanning, f.Query, len(f.Matches)}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Visible, Scanning bool
Query string
N int
})
if got.Visible {
t.Errorf("find bar visible = true, want false")
}
if got.Query != "needle" {
t.Errorf("query = %q, want needle", got.Query)
}
if got.Scanning {
t.Errorf("scanning = true after a closed-bar restore; the scan would be dropped and never re-run")
}
if got.N != 0 {
t.Errorf("matches = %d, want 0 (no scan while the bar is closed)", got.N)
}
// Opening the bar now must re-scan (findShow) and find all three.
if err := h.WithState(func(st *editor.State) { editor.ToggleFind(nil) }); err != nil {
t.Fatalf("ToggleFind: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for {
n, err := h.Inspect(func(st *editor.State) any {
return len(st.Editor.Find.Matches)
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
if n.(int) == 3 {
return
}
if time.Now().After(deadline) {
t.Fatal("timed out: opening the find bar did not re-scan the restored query")
}
time.Sleep(25 * time.Millisecond)
}
}
// TestRestore_SaverSnapshot verifies the persist side: a state change
// (cursor + selection + scroll) is picked up by the rate-limited saver as a
// snapshot with the right values.
func TestRestore_SaverSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "small.txt"), []byte("hello world"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s })
h.Run()
h.SendConfig(780, 1688)
h.SendScale(2.0)
defer h.Cleanup()
// Open the file the usual way (browser-row tap path).
if err := h.WithState(func(st *editor.State) { editor.OpenFile("/small.txt") }); err != nil {
t.Fatalf("OpenFile: %v", err)
}
time.Sleep(300 * time.Millisecond) // let the load settle
// Change state on the owner, then force a frame (emitFrame drives the
// rate-limited save).
if err := h.WithState(func(st *editor.State) {
st.Editor.CursorPosition = 6
st.Editor.SelectionAnchor = 0
st.Editor.SelectionStart = 0
st.Editor.SelectionEnd = 5
st.ScrollOffset = 10
}); err != nil {
t.Fatalf("WithState: %v", err)
}
// The save is rate-limited to one per second; wait past the limit, then
// force a frame (emitFrame drives the check).
time.Sleep(1100 * time.Millisecond)
h.SendConfig(780, 1688)
deadline := time.Now().Add(5 * time.Second)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
t.Fatal("timed out: saver did not receive the expected snapshot")
}
select {
case s := <-saves:
if s.File == "/small.txt" && s.Cursor == 6 &&
s.SelStart == 0 && s.SelEnd == 5 && s.Scroll == 10 {
return
}
case <-time.After(remaining):
t.Fatal("timed out: saver did not receive the expected snapshot")
}
}
}
// TestRestore_ShutdownSavesFinalSnapshot verifies Shutdown's unconditional
// final save: the last state is persisted even without a rate-limited tick.
func TestRestore_ShutdownSavesFinalSnapshot(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {})
l.SetSessionSaver(func(s editor.SessionState) { saves <- s })
go l.Run()
// Open the file (the goroutine-send keeps this off the owner's select).
if _, ok := l.Inspect(func(st *editor.State) any {
editor.OpenFile("/a.txt")
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
for i := 0; i < 100; i++ {
loaded, ok := l.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() == 6
})
if ok && loaded.(bool) {
break
}
time.Sleep(20 * time.Millisecond)
}
if _, ok := l.Inspect(func(st *editor.State) any {
st.Editor.CursorPosition = 3
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
l.Shutdown()
deadline := time.After(3 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/a.txt" && s.Cursor == 3 {
return
}
case <-deadline:
t.Fatal("timed out: Shutdown did not persist a snapshot with the final state")
}
}
}
// TestRestore_ScrollSurvivesWrapState reproduces the on-device report
// "relaunch lands further DOWN than where I left off": the snapshot's pixel
// scroll lives in visual-line space (wrapped lines occupy several visual
// lines, and the scroll-to-line mapping runs through the WrapIndex), but on
// relaunch the counts of every line above the restored viewport are the
// estimate (1) until shaped, and lines above the visible window are never
// shaped. Mapping the saved offset through that fresh index lands a logical
// line deeper by all the wrapped continuations above it. The snapshot
// therefore persists the logical line at the viewport top (and the sub-line
// remainder), and the restore derives the offset from the line.
func TestRestore_ScrollSurvivesWrapState(t *testing.T) {
const lines = 300
var b strings.Builder
for i := 0; i < lines; i++ {
fmt.Fprintf(&b, "line %03d content\n", i)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "wrap.txt"), []byte(b.String()), 0644); err != nil {
t.Fatal(err)
}
// First session: open the file, record the first 150 lines as wrapped
// into 3 visual lines each (as a shaping pass would), and scroll so the
// viewport top sits on logical line 200. In visual space that is line
// 150*3 + 50 = 500, so the pixel offset is 500 line-heights.
saves := make(chan editor.SessionState, 16)
h1 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h1.Logic().SetSessionSaver(func(s editor.SessionState) { saves <- s })
h1.Run()
h1.SendConfig(780, 1688)
h1.SendScale(2.0)
defer h1.Cleanup()
if err := h1.WithState(func(st *editor.State) { editor.OpenFile("/wrap.txt") }); err != nil {
t.Fatalf("OpenFile: %v", err)
}
for i := 0; i < 100; i++ {
v, err := h1.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() > 0 && cb.LineIndex != nil
})
if err == nil && v.(bool) {
break
}
time.Sleep(20 * time.Millisecond)
}
// Set the wrapped counts and the deep scroll on the owner; the line
// height comes from the owner too (EffectiveLineHeight reads state).
lhAny, err := h1.Inspect(func(st *editor.State) any {
w := st.Editor.ChunkedBuffer.WrapIndex
for i := 0; i < 150; i++ {
w.Set(i, 3)
}
st.ScrollOffset = ui.Dp(500 * float64(editor.EffectiveLineHeight()))
return float64(editor.EffectiveLineHeight())
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
lh := lhAny.(float64)
// Wait past the rate limit and force a frame: the saver must capture
// the deep scroll together with its logical line (200). Match on the
// line, not the exact Dp: ui.Dp is float32-based, so ui.Dp(500*lh) and
// 500*float64(lh) round differently.
time.Sleep(300 * time.Millisecond)
h1.SendConfig(780, 1688)
snap := editor.SessionState{}
deadline := time.After(5 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/wrap.txt" && s.ScrollLine == 200 && s.Scroll > 0 {
snap = s
goto got
}
case <-deadline:
t.Fatal("timed out: saver did not capture the deep-scroll snapshot")
}
}
got:
if snap.Scroll < 499*lh || snap.Scroll > 501*lh {
t.Fatalf("snapshot Scroll = %v, want ~500*lh (%v): the pixel offset must stay the visual-space value",
snap.Scroll, 500*lh)
}
if snap.ScrollLine != 200 {
t.Fatalf("snapshot ScrollLine = %d, want 200 (the logical line at the viewport top under wrap)", snap.ScrollLine)
}
if snap.ScrollSub >= 0.01 { // 0 apart from ui.Dp float32 rounding
t.Errorf("snapshot ScrollSub = %v, want ~0", snap.ScrollSub)
}
// Second session (the relaunch): the WrapIndex is fresh, every count the
// estimate (1). The restore must land on logical line 200, not on visual
// line 500 mapped 1:1 (which would be line 500, far deeper).
h2 := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h2.Logic().BeginRestore(snap)
h2.Run()
h2.SendConfig(780, 1688)
h2.SendScale(2.0)
defer h2.Cleanup()
// The restore must land the scroll within one line of 200*lh (the ui.Dp
// float32 rounding keeps an exact match out of reach); the exact
// assertion is the window start line below.
for i := 0; i < 100; i++ {
v, err := h2.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return false
}
s := float64(st.ScrollOffset)
return s > 199*lh && s < 201*lh
})
if err == nil && v.(bool) {
break
}
time.Sleep(20 * time.Millisecond)
}
// One more frame so the layout pass records the window start line for
// the landed scroll.
h2.SendConfig(780, 1688)
time.Sleep(100 * time.Millisecond)
v, err := h2.Inspect(func(st *editor.State) any {
return struct {
Scroll ui.Dp
WinLine int
}{st.ScrollOffset, st.WindowStartLine}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Scroll ui.Dp
WinLine int
})
if s := float64(got.Scroll); s < 199*lh || s > 201*lh {
t.Errorf("restored scroll = %v, want ~200*lh (%v): the saved logical line's offset",
got.Scroll, 200*lh)
}
if got.WinLine != 200 {
t.Errorf("window starts at line %d, want 200 (the saved line); a deeper line is the pre-fix visual-space mapping", got.WinLine)
}
}
// TestRestore_LinePinHoldsAcrossLateWrapFeedback reproduces the on-device
// report "relaunch lands further UP than where I left off" (Pixel 9 Pro):
// while the restore scroll is still armed, the top-of-file window is what's
// rendered, and its shaping feedback (real wrap counts for the lines ABOVE
// the restored line) can land before or just after the offset is applied.
// Those counts are correct data, but they change the offset-to-line mapping:
// the line-derived offset was synthesized for the all-estimate index, so
// once counts land below the pinned line the same offset maps to a shallower
// line and the viewport drifts up. The restore therefore pins the logical
// line: every wrap correction re-derives the offset as V(L)*lh + sub until
// the restored window itself has shaped (or a timeout, or the user scrolls).
func TestRestore_LinePinHoldsAcrossLateWrapFeedback(t *testing.T) {
const lines = 300
const lineLen = 17 // "line %03d content\n"
var b strings.Builder
for i := 0; i < lines; i++ {
fmt.Fprintf(&b, "line %03d content\n", i)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "wrap.txt"), []byte(b.String()), 0644); err != nil {
t.Fatal(err)
}
// The harness runs at the default font scale, so the stateless variant
// matches what the owner will compute.
lh := float64(editor.EffectiveLineHeightAt(1))
snap := editor.SessionState{
File: "/wrap.txt",
Cursor: 0,
Scroll: float64(ui.Dp(500 * lh)), // the saving session's visual-space offset
ScrollLine: 200, // the logical line at the viewport top
ScrollSub: 0,
}
h := e2e.NewHarness(e2e.WithFileSystem(real.NewRealFileSystem(dir), "/"))
h.Logic().BeginRestore(snap)
h.Run()
h.SendConfig(780, 1688)
h.SendScale(2.0)
defer h.Cleanup()
// Wait for the restore to land: with the fresh all-estimate index the
// offset maps to exactly line 200.
var winLine int
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
if cb == nil || cb.FileLen() == 0 || cb.LineIndex == nil {
return -1
}
return st.WindowStartLine
})
if err == nil {
winLine = v.(int)
if winLine == 200 {
break
}
}
time.Sleep(20 * time.Millisecond)
}
if winLine != 200 {
t.Fatalf("restore did not land on line 200 (window at %d)", winLine)
}
// Replay the on-device interleaving: the top-of-file window (lines
// 0-49) was shaped while the scroll was still armed, and its feedback —
// real wrap counts (3 visual lines each for those lines) — arrives now,
// after the offset was applied.
editSeq, err := h.Inspect(func(st *editor.State) any {
return st.Editor.EditSeq
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
var wt strings.Builder
for i := 0; i < 50; i++ {
fmt.Fprintf(&wt, "line %03d content\n", i)
}
windowText := wt.String()
var starts []int
for i := 0; i < 50; i++ {
base := i * lineLen
starts = append(starts, base, base+5, base+10) // 3 visual lines per line
}
h.Logic().LayoutChan() <- ui.LayoutFeedback{
GlyphLayout: ui.GlyphLayout{
VisualLineStarts: starts,
},
WindowText: windowText,
WindowStartByte: 0,
WindowStartLine: 0,
EditSeq: editSeq.(uint64),
}
// The correction must NOT drag the viewport: the pin re-derives the
// offset as V(200)*lh, where V(200) = 50*3 + 150 = 300 under the
// corrected index, keeping line 200 at the top. Pre-fix, the applied
// offset (200*lh) mapped to line ~66 under the corrected index.
for i := 0; i < 100; i++ {
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Scroll float64
WinLine int
}{float64(st.ScrollOffset), st.WindowStartLine}
})
if err == nil {
got := v.(struct {
Scroll float64
WinLine int
})
if got.WinLine == 200 && got.Scroll > 299*lh && got.Scroll < 301*lh {
return // held
}
}
time.Sleep(20 * time.Millisecond)
}
v, err := h.Inspect(func(st *editor.State) any {
return struct {
Scroll float64
WinLine int
}{float64(st.ScrollOffset), st.WindowStartLine}
})
if err != nil {
t.Fatalf("Inspect: %v", err)
}
got := v.(struct {
Scroll float64
WinLine int
})
if got.WinLine != 200 {
t.Errorf("window at line %d after late wrap feedback, want 200: the line-pin must hold the viewport on the restored line (a shallower line is the pre-fix drift)", got.WinLine)
}
if got.Scroll < 499*lh || got.Scroll > 501*lh {
t.Errorf("scroll = %v after late wrap feedback, want ~300*lh (%v): V(200) under the corrected index", got.Scroll, 300*lh)
}
}
// TestRestore_UrgentSaveOnSelection verifies the kill-race fix: a selection
// appearing (the user just highlighted text) saves immediately, even inside
// the rate-limit window that the file-open save opened. Without the urgent
// path, killing the app (recents-wipe) less than a second after highlighting
// lost the selection and cursor.
func TestRestore_UrgentSaveOnSelection(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abcdef"), 0644); err != nil {
t.Fatal(err)
}
saves := make(chan editor.SessionState, 16)
l := editor.NewLogic(real.NewRealFileSystem(dir), "/", func(string) {})
l.SetSessionSaver(func(s editor.SessionState) { saves <- s })
go l.Run()
defer l.Shutdown()
// Open the file: the file change is itself urgent, so a save lands as
// the load settles. Drain everything up to and including the loaded
// snapshot.
if _, ok := l.Inspect(func(st *editor.State) any {
editor.OpenFile("/a.txt")
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
loaded := false
for i := 0; i < 100 && !loaded; i++ {
v, ok := l.Inspect(func(st *editor.State) any {
cb := st.Editor.ChunkedBuffer
return cb != nil && cb.FileLen() == 6
})
if ok {
loaded = v.(bool)
}
if loaded {
break
}
time.Sleep(20 * time.Millisecond)
}
if !loaded {
t.Fatal("file did not load")
}
// Drain saves up to the first one describing the loaded file.
deadline := time.After(3 * time.Second)
for {
select {
case s := <-saves:
if s.File == "/a.txt" {
goto drainDone
}
case <-deadline:
t.Fatal("timed out: no save for the opened file")
}
}
drainDone:
// Immediately (well inside any rate-limit window after the saves above),
// make a selection appear and force a frame: the urgent path must save it
// now, not after the interval elapses.
if _, ok := l.Inspect(func(st *editor.State) any {
st.Editor.SelectionAnchor = 1
st.Editor.SelectionStart = 1
st.Editor.SelectionEnd = 4
st.Editor.CursorPosition = 4
st.ScrollOffset = 3
return nil
}); !ok {
t.Fatal("Inspect timed out")
}
l.ConfigChan() <- editor.ConfigEvent{PixelWidth: 780, PixelHeight: 1688} // force emitFrame
select {
case s := <-saves:
if s.SelStart == 1 && s.SelEnd == 4 && s.Cursor == 4 {
return
}
t.Fatalf("unexpected snapshot: %+v", s)
case <-time.After(2 * time.Second):
t.Fatal("timed out: selection appearance was not saved immediately (urgent path)")
}
}

View File

@ -248,7 +248,7 @@ func TestSortModeChangePreservesSearchFilter(t *testing.T) {
// When the sort mode changes, the position map changes,
// so the *index* of the entry "Documents" (which matches "doc")
// will change!
// Let's print the entries to see if they are still correct,
// ignoring the index values themselves.
// (Owner-side snapshot: all reads happen on the logic goroutine.)

View File

@ -900,6 +900,11 @@ const (
// The renderer registers the underlying gesture.Drag ops itself (it owns
// the handle geometry); this interaction just delivers the logic handler.
SelDrag
// Pinch is a two-finger pinch inside the element's text region. The
// renderer owns the probe (it needs raw two-pointer geometry that no
// single gesture primitive in Gio v0.10 provides); this interaction just
// delivers the logic handler, which receives FontPinchEvent.
Pinch
)
// Interaction pairs a gesture type with a handler function.
@ -950,6 +955,19 @@ type SelectionDragEvent struct {
// SelectionDragEnd is emitted when a selection/caret drag is released.
type SelectionDragEnd struct{}
// FontPinchEvent carries one frame's relative two-finger pinch factor
// (current inter-finger distance / previous frame's distance, both in px).
// The logic applies it as a multiplier to the app-local font scale; the
// value is a plain float32 ratio with no rounding, so the font size is
// continuous, never snapped to whole points. Center is the pinch midpoint
// (the average of the two fingers) in app-local window Dp — the same space
// as Point; the logic anchors the content under this point so it stays put
// while the font scales.
type FontPinchEvent struct {
Scale float32
Center Point
}
// MenuItem is one button in the selection menu.
// X/Y/W/H are relative to the Menu region.
type MenuItem struct {

View File

@ -2,14 +2,14 @@ package ui
const (
// Standard dimensions in Dp
StatusBarLineHeight = Dp(24)
StatusBarLineHeight = Dp(24)
StatusBarFilenameLine = Dp(24)
StatusBarIconsLine = Dp(24)
BottomBarHeight = Dp(24)
IconSize = Dp(24)
IconGap = Dp(36)
Padding = Dp(8)
ButtonPadding = Dp(8)
StatusBarIconsLine = Dp(24)
BottomBarHeight = Dp(24)
IconSize = Dp(24)
IconGap = Dp(36)
Padding = Dp(8)
ButtonPadding = Dp(8)
)
// LineHeightScale is the baseline-to-baseline spacing multiplier for editor text.

510
internal/ui/pinch_test.go Normal file
View File

@ -0,0 +1,510 @@
package ui
import (
"math"
"testing"
"time"
"gioui.org/f32"
"gioui.org/io/pointer"
)
// pev builds a synthetic pointer event. Time is explicit: the Android driver
// supplies it per event and the freshness window runs on it.
func pev(kind pointer.Kind, id pointer.ID, x, y float32, at time.Duration) pointer.Event {
return pointer.Event{Kind: kind, PointerID: id, Position: f32.Point{X: x, Y: y}, Time: at}
}
// ms/s are time helpers so test timelines read as intended (a bare integer
// constant is nanoseconds — the first draft's "2000ms" was 2µs).
func ms(n int) time.Duration { return time.Duration(n) * time.Millisecond }
func s(n int) time.Duration { return time.Duration(n) * time.Second }
// runFrame feeds a batch of events (one frame's drain) through the tracker
// and returns the frame's factor, the grabs it asked for, and the
// survivor-finger scroll, mirroring consumePinchProbe.
func runFrame(t *pinchTracker, evts ...pointer.Event) (f float32, mid f32.Point, ok bool, grabs []pointer.ID, scroll int) {
for _, e := range evts {
if s := t.step(e); len(s.grabs) > 0 {
grabs = append(grabs, s.grabs...)
}
}
f, mid, ok, g2 := t.factor()
grabs = append(grabs, g2...)
scroll = t.survivorScroll()
return
}
// approx reports whether a and b are within 1e-3.
func approx(a, b float64) bool { return math.Abs(a-b) < 1e-3 }
// A clean two-finger pinch: the pair forms when BOTH fingers move (the
// two-mover rule), owes the spread that happened by then (baseline = press
// distance), then one factor per frame whose product telescopes to the total
// distance ratio — the property that makes the font track the fingers.
func TestTrackerPinchFactorSeries(t *testing.T) {
var tr pinchTracker
// Frame 1: both fingers down 200px apart. Pending: no pair, no grabs,
// no factor (a press alone is not a pinch).
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
)
if ok || len(grabs) != 0 {
t.Fatalf("press-only frame: ok=%v grabs=%v want nothing", ok, grabs)
}
// Frames 2-4: the pair spreads 200 -> 240 -> 300 -> 250. Frame 2 is the
// formation: both fingers moved 20px (movers), grabs issued, and the
// owed factor is measured against the PRESS distance (200).
f2, _, ok2, g2, _ := runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11),
)
if !ok2 || !approx(float64(f2), 240.0/200) {
t.Fatalf("f2=%v ok=%v want %v", f2, ok2, 240/200)
}
if len(g2) != 2 {
t.Fatalf("formation frame must grab both fingers, got %v", g2)
}
f3, mid3, ok3, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 50, 100, 20),
pev(pointer.Drag, 1, 350, 100, 21),
)
if !ok3 || !approx(float64(f3), 300.0/240) {
t.Fatalf("f3=%v ok=%v want %v", f3, ok3, 300/240)
}
if mid3 != (f32.Point{X: 200, Y: 100}) {
t.Fatalf("mid3=%v want (200,100)", mid3)
}
f4, _, ok4, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 125, 100, 30),
pev(pointer.Drag, 1, 375, 100, 31),
)
if !ok4 || !approx(float64(f4), 250.0/300) {
t.Fatalf("f4=%v ok=%v want %v", f4, ok4, 250.0/300)
}
total := f2 * f3 * f4
if !approx(float64(total), 250.0/200) {
t.Fatalf("product=%v want total ratio %v", total, 250/200)
}
}
// A single finger — press, hold, long scroll — must never produce a factor
// or a grab. This is the device regression "one-finger scroll changes the
// font": the old code paired the scroll finger with whatever stale pointer
// was in the map.
func TestTrackerSingleFingerNeverScales(t *testing.T) {
var tr pinchTracker
// A pinch earlier in the session...
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
)
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11),
)
runFrame(&tr,
pev(pointer.Release, 0, 80, 100, 20),
pev(pointer.Release, 1, 320, 100, 21),
)
// ...now a single finger scrolls for many frames.
for i := 0; i < 20; i++ {
y := float32(400 - i*20)
_, _, ok, grabs, scroll := runFrame(&tr,
pev(pointer.Drag, 5, 200, y, time.Duration(100+i*10)))
if ok || len(grabs) != 0 || scroll != 0 {
t.Fatalf("frame %d: single finger produced factor ok=%v grabs=%v scroll=%v",
i, ok, grabs, scroll)
}
}
// Fresh pointer IDs (Android resets them after full release) — still nothing.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 200, 500, 500),
)
if ok || len(grabs) != 0 {
t.Fatalf("fresh single finger: ok=%v grabs=%v", ok, grabs)
}
for i := 0; i < 10; i++ {
_, _, ok, grabs, _ = runFrame(&tr,
pev(pointer.Drag, 0, 200, float32(500-i*15), time.Duration(510+i*10)))
if ok || len(grabs) != 0 {
t.Fatalf("scroll frame %d: ok=%v grabs=%v", i, ok, grabs)
}
}
}
// Device regression "sudden dramatic zoom before pinching": a finger that
// has been RESTING (palm edge, parked pinky) must not be paired with a new
// one. The freshness window prunes it; the pair forms only from fresh
// fingers.
func TestTrackerRestingFingerNotPaired(t *testing.T) {
var tr pinchTracker
// Palm rests at t=0.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 3, 600, 800, 0))
if ok || len(grabs) != 0 {
t.Fatal("one resting finger must not pinch")
}
// At t=2s the thumb lands: the palm is stale (>300ms) and pruned.
_, _, ok, grabs, _ = runFrame(&tr,
pev(pointer.Press, 0, 100, 100, s(2)),
pev(pointer.Drag, 3, 605, 800, s(2)+ms(1)), // palm still there, drifting
)
if ok || len(grabs) != 0 {
t.Fatalf("resting palm + new finger must not form a pair (ok=%v grabs=%v)", ok, grabs)
}
// At t=2.1s the index lands: two fresh fingers -> pending (formation
// waits for a third finger or the first movement; the palm is stale).
_, _, _, grabs, _ = runFrame(&tr,
pev(pointer.Press, 1, 300, 100, s(2)+ms(100)))
if len(grabs) != 0 {
t.Fatalf("pending pair must not grab yet, grabs=%v", grabs)
}
// The palm keeps drifting and the pair starts moving: the pair is
// (thumb, index) and its distance is unaffected by the palm.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 3, 640, 810, s(2)+ms(200)), // palm moves (pruned, ignored)
pev(pointer.Drag, 0, 80, 100, s(2)+ms(201)),
pev(pointer.Drag, 1, 320, 100, s(2)+ms(202)),
)
if len(grabs) != 2 {
t.Fatalf("thumb+index must form the pair on movement, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 240.0/200) {
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 240/200)
}
}
// Device regression "pinch dies and becomes scroll": the pair must survive
// finger movement past the scroll slop. In the router this is guaranteed by
// the grabs (scroll is dropped from the pair's path); here we assert the
// state machine keeps emitting factors for a pair that moves a lot, and
// ignores drags of pointers that are not the pair.
func TestTrackerPinchSurvivesLargeMoves(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Big outward moves (far past the ~30px scroll slop), 5 frames.
want := []float32{1.5, 1.4, 1.3, 1.2, 1.1}
d := 200.0
for i := 0; i < 5; i++ {
d *= float64(want[i])
half := d / 2
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 200-float32(half), 100, time.Duration(10+i*10)),
pev(pointer.Drag, 1, 200+float32(half), 100, time.Duration(11+i*10)),
)
if !ok {
t.Fatalf("frame %d: factor stopped (pinch died)", i)
}
}
}
// Lifting one finger of the pair: no more factors (the pair is gone), the
// survivor's drags come out as scroll (forwarded), and returning a second
// finger re-forms the pair with a fresh baseline.
func TestTrackerSurvivorScrollAndReform(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Pinch in a bit.
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11))
// Finger 0 lifts. Finger 1 survives.
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Release, 0, 80, 100, 20))
if ok {
t.Fatal("broken pair must not emit a factor")
}
// The survivor scrolls: 40px up over two frames -> +40, +25.
_, _, _, _, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 320, 60, 30))
if scroll != 40 {
t.Fatalf("survivor scroll=%d want 40", scroll)
}
_, _, _, _, scroll = runFrame(&tr,
pev(pointer.Drag, 1, 320, 35, 40))
if scroll != 25 {
t.Fatalf("survivor scroll=%d want 25", scroll)
}
// A second finger returns: CANDIDATE for a re-form; the pair re-forms
// only when the new finger MOVES (two-mover rule — a palm resting on
// the survivor is not a pinch).
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 2, 50, 35, 50))
if ok || len(grabs) != 0 {
t.Fatalf("candidate press must not re-form yet (ok=%v grabs=%v)", ok, grabs)
}
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 1, 320, 35, 60),
pev(pointer.Drag, 2, 30, 35, 61)) // 20px from press: a real second finger
if len(grabs) != 1 || grabs[0] != 2 {
t.Fatalf("re-form grabs=%v want [2] (only the new finger)", grabs)
}
// Baseline = the distance at the candidate's PRESS (270 = 320-50); the
// spread to 290 by re-form time is owed, not lost.
if !ok || !approx(float64(f), 290.0/270) {
t.Fatalf("post-reform f=%v ok=%v want %v", f, ok, 290/270)
}
// Survivor lifts: fully idle again.
_, _, ok, _, scroll = runFrame(&tr,
pev(pointer.Release, 1, 320, 35, 70))
if ok || scroll != 0 {
t.Fatal("idle state must emit nothing")
}
}
// Device regression "dramatic zoom before the pinch starts": a NEW pinch
// must never emit a factor on its formation frame, even if the previous
// pinch ended at a very different distance (stale baseline).
func TestTrackerFreshBaselineEachPinch(t *testing.T) {
var tr pinchTracker
// Pinch A at ~200px.
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Release, 0, 100, 100, 10),
pev(pointer.Release, 1, 300, 100, 11))
// Pinch B starts 500px apart (the fingers landed far apart). The old
// code would have emitted 500/200 = 2.5x on the first frame.
_, _, ok, _, _ := runFrame(&tr,
pev(pointer.Press, 0, 0, 100, 100),
pev(pointer.Press, 1, 500, 100, 101))
if ok {
t.Fatal("second pinch's formation frame must not emit a factor (stale baseline)")
}
f, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, -20, 100, 110),
pev(pointer.Drag, 1, 520, 100, 111))
if !ok || !approx(float64(f), 540.0/500) {
t.Fatalf("f=%v ok=%v want %v (baseline = this pinch's own start)", f, ok, 540/500)
}
}
// A third finger landing DURING an active pinch is ignored (palm rest): the
// pair is fixed, its distance is untouched, and no extra grabs are issued.
func TestTrackerExtraFingerDuringPinchIgnored(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Palm lands and sits.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 4, 700, 900, 10))
if ok || len(grabs) != 0 {
t.Fatalf("extra finger during pinch: ok=%v grabs=%v", ok, grabs)
}
// Palm drifts a bit; the two pinch fingers move (forming the pair); the
// palm is not in the pair and its distance is untouched.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 4, 710, 910, 20), // 14px: a mover, but its distance to the fingers changes little
pev(pointer.Drag, 0, 80, 100, 21),
pev(pointer.Drag, 1, 320, 100, 22))
if len(grabs) != 2 {
t.Fatalf("pinch fingers must form the pair, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 240.0/200) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 240/200)
}
// Palm lifts: still nothing.
_, _, ok, _, _ = runFrame(&tr,
pev(pointer.Release, 4, 750, 950, 30))
if ok {
t.Fatal("palm release must not emit a factor")
}
}
// A global cancel (app switch) breaks the pair with no survivor.
func TestTrackerCancelBreaksPair(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Drag, 0, 80, 100, 10),
pev(pointer.Drag, 1, 320, 100, 11))
// App switch: cancels for both pointers.
_, _, ok, _, scroll := runFrame(&tr,
pev(pointer.Cancel, 0, 0, 0, 20),
pev(pointer.Cancel, 1, 0, 0, 21))
if ok || scroll != 0 {
t.Fatal("cancel must break the pair cleanly")
}
// The cancelled drags must not resurrect anything.
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 0, 70, 100, 30))
if ok || len(grabs) != 0 {
t.Fatal("post-cancel drags must be inert")
}
}
// A finger that leaves the region pre-pinch is no longer a candidate:
// press-leave-press must not form a pair.
func TestTrackerLeaveCancelsCandidate(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0))
runFrame(&tr,
pev(pointer.Drag, 0, 500, 1500, 10), // leaves the region
pev(pointer.Leave, 0, 500, 1500, 11))
_, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 1, 300, 100, 20))
if ok || len(grabs) != 0 {
t.Fatalf("leaving finger must not pair with a new one (ok=%v grabs=%v)", ok, grabs)
}
}
// A pair broken by one finger lifting, then the survivor lifting too: the
// state must be fully idle (a later single-finger scroll emits nothing).
func TestTrackerFullyIdleAfterSurvivorLift(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
runFrame(&tr,
pev(pointer.Release, 0, 100, 100, 10))
runFrame(&tr,
pev(pointer.Drag, 1, 300, 60, 20)) // survivor scrolls
runFrame(&tr,
pev(pointer.Release, 1, 300, 60, 30))
for i := 0; i < 5; i++ {
_, _, ok, grabs, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 300, float32(60-i*20), time.Duration(40+i*10)))
if ok || len(grabs) != 0 || scroll != 0 {
t.Fatalf("frame %d: not idle (ok=%v grabs=%v scroll=%v)", i, ok, grabs, scroll)
}
}
}
// The sanity clamp: a factor outside 0.1..10 (teleporting pair) is dropped
// and the baseline advances, so one bad frame cannot jump the font.
func TestTrackerSanityClamp(t *testing.T) {
var tr pinchTracker
runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1))
// Teleport: finger 0 jumps 3000px (ID-reuse noise) while finger 1 moves
// 20px: both are movers, the pair forms, distance 200 -> 2800, factor
// 14x — must be dropped, baseline advanced.
f, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 0, 3100, 100, ms(10)),
pev(pointer.Drag, 1, 320, 100, ms(11)))
if ok {
t.Fatalf("implausible factor emitted: %v", f)
}
// Next frame is sane relative to the NEW baseline (2800px).
f, _, ok, _, _ = runFrame(&tr,
pev(pointer.Drag, 0, 3050, 100, ms(20)),
pev(pointer.Drag, 1, 325, 100, ms(21)))
if !ok || !approx(float64(f), 2725.0/2780) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 2725.0/2780)
}
}
// A slow frame: the whole pinch (formation + spread + both releases) can
// arrive in ONE frame's drain (the Android driver replays historical samples
// and a low frame rate batches events). The factors must still be owed:
// the formation-frame movement relative to the formation distance, and the
// final movement settled when the pair breaks.
func TestTrackerSlowFrameFullPinch(t *testing.T) {
var tr pinchTracker
// Frame 1: both Presses (200px apart) and drags out to 300px, all in
// one drain. The pair forms at 200 and moved to 300 this frame:
// factor 300/200 owed.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Drag, 0, 50, 100, 2),
pev(pointer.Drag, 1, 350, 100, 3))
if !ok || !approx(float64(f), 300.0/200) {
t.Fatalf("f1=%v ok=%v want %v (formation-frame movement is owed)", f, ok, 300.0/200)
}
if len(grabs) != 2 {
t.Fatalf("grabs=%v want 2", grabs)
}
// Frame 2: spread to 400px, then both fingers lift — same drain. The
// 400/300 factor must be settled at the break, not lost.
f, _, ok, _, _ = runFrame(&tr,
pev(pointer.Drag, 0, 0, 100, 10),
pev(pointer.Drag, 1, 400, 100, 11),
pev(pointer.Release, 0, 0, 100, 12),
pev(pointer.Release, 1, 400, 100, 13))
if !ok || !approx(float64(f), 400.0/300) {
t.Fatalf("f2=%v ok=%v want %v (break settles the owed factor)", f, ok, 400.0/300)
}
// Fully idle after the break.
if _, _, ok, _, scroll := runFrame(&tr,
pev(pointer.Drag, 1, 400, 80, 20)); ok || scroll != 0 {
t.Fatal("tracker must be idle after both releases")
}
}
// A stationary pair that forms and then breaks without moving owes nothing.
func TestTrackerSlowFrameNoMovement(t *testing.T) {
var tr pinchTracker
if _, _, ok, _, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Release, 0, 100, 100, 2),
pev(pointer.Release, 1, 300, 100, 3)); ok {
t.Fatal("stationary pair that breaks must not emit a factor")
}
}
// The worst case (a ~1fps emulator frame): the ENTIRE pinch — formation,
// spread, and both releases — lands in one frame's drain. The owed factor
// is settled at the break against the formation distance.
func TestTrackerBornAndDeadInOneFrame(t *testing.T) {
var tr pinchTracker
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Press, 0, 100, 100, 0),
pev(pointer.Press, 1, 300, 100, 1),
pev(pointer.Drag, 0, 0, 100, 2),
pev(pointer.Drag, 1, 400, 100, 3),
pev(pointer.Release, 0, 0, 100, 4),
pev(pointer.Release, 1, 400, 100, 5))
if !ok || !approx(float64(f), 400.0/200) {
t.Fatalf("f=%v ok=%v want %v", f, ok, 400.0/200)
}
if len(grabs) != 2 {
t.Fatalf("grabs=%v want 2", grabs)
}
if _, _, ok, _, _ := runFrame(&tr,
pev(pointer.Drag, 1, 400, 80, 10)); ok {
t.Fatal("idle tracker must not emit")
}
}
// Palm-first three-finger: the palm edge is resting and still, then the two
// pinch fingers land (all within the freshness window). The pair must be the
// two NEWEST fingers — the pinch pair — not the palm.
func TestTrackerPalmFirstThreeFingers(t *testing.T) {
var tr pinchTracker
// Palm rests at t=0 (still), pinch fingers land 80/120ms later.
// Pending: no pair yet (movement decides).
_, _, _, grabs, _ := runFrame(&tr,
pev(pointer.Press, 3, 720, 400, 0),
pev(pointer.Press, 0, 570, 1200, ms(80)),
pev(pointer.Press, 1, 870, 1200, ms(120)))
if len(grabs) != 0 {
t.Fatalf("pending: no grabs yet, got %v", grabs)
}
// The pinch spreads 300 -> 596 while the palm stays put: the two MOVERS
// (the pinch fingers) form the pair, and the factor must track the
// pinch pair — not the palm.
f, _, ok, grabs, _ := runFrame(&tr,
pev(pointer.Drag, 0, 422, 1200, ms(200)),
pev(pointer.Drag, 1, 1018, 1200, ms(201)),
pev(pointer.Drag, 3, 720, 400, ms(202))) // palm still
if len(grabs) != 2 || (grabs[0] != 0 && grabs[0] != 1) || (grabs[1] != 0 && grabs[1] != 1) {
t.Fatalf("pair must be the two pinch fingers, grabs=%v", grabs)
}
if !ok || !approx(float64(f), 596.0/300) {
t.Fatalf("f=%v ok=%v want %v (palm must not enter the distance)", f, ok, 596.0/300)
}
}

View File

@ -0,0 +1,470 @@
package ui
import (
"math"
"time"
"gioui.org/f32"
"gioui.org/io/pointer"
)
// pinchFreshWindow is how recently BOTH pair fingers must have pressed for a
// pinch to start. A finger that has been resting (palm edge, pinky parked on
// the screen) is not a pinch candidate; without this window a resting third
// finger could be paired with a new one and the font would follow a
// single-finger scroll.
const pinchFreshWindow = 300 * time.Millisecond
// pinchMoveEps is how far (window px, from the press position) a pending
// finger must travel to count as "moving" for pair formation. It filters
// touch jitter while still registering an intentional pinch start quickly.
const pinchMoveEps = 10.0
// Pair formation requires TWO MOVING fingers, and nothing else. A pinch is
// two fingers moving apart (or together); a scroll is one finger moving;
// a resting palm is a finger that never moves. No static rule about which
// finger is "the palm" (first? last? still?) survives both palm-first and
// palm-last hand lands — movement is the only signal that works for both.
// While 2-3 fresh fingers are down the tracker is "pending": it forms the
// pair the moment two of them have moved more than pinchMoveEps from where
// they pressed (the pair = those two). A lone mover is a scroll, never a
// pinch, and cannot form a pair; the pending state cancels on any release
// that leaves fewer than two fingers.
// pinchTracker is the pure state machine for the editor's two-finger pinch
// (font scaling). It is independent of the Gio input router so the whole
// gesture — including the failure modes found on device (pair switching,
// stale baselines, scroll-grab starvation) — is unit-testable with synthetic
// pointer-event sequences.
//
// Design: the pair is EXPLICIT and stable. When two fresh fingers are down
// inside the region, they are named as the pair and the adapter issues
// pointer.GrabCmds for both (exclusive event delivery: releases always
// arrive, even off-clip, and scroll/click are dropped with a Cancel). The
// per-frame factor is current pair distance / previous frame's pair
// distance. Neither the pair's composition nor its baseline is ever
// re-derived from whatever pointers happen to be present — that re-derivation
// (two lowest IDs + stale prevDist) is what made single-finger scrolls scale
// the font on the phone.
type pinchTracker struct {
// Pre-pinch ("pending"): fresh fingers down in the region, no pair yet.
// The pair forms when TWO of them have moved more than pinchMoveEps
// from where they pressed (see the comment above the constants).
observed map[pointer.ID]f32.Point
observedAt map[pointer.ID]time.Duration
// Position each pending finger had when it PRESSED: the formation
// baseline (formDist) is the press distance, so a pinch that has
// already spread by the time the pair starts owes that spread; and the
// displacement from here is the "mover" measurement.
pressPos map[pointer.ID]f32.Point
// Pending fingers that released in the CURRENT frame's drain, held lazy
// until factor() (which forms the pair — and may break it again — at
// the fingers' final positions). A normal active-pair release breaks
// immediately in step(); this map is only for releases that arrive
// while the pair is still pending.
released map[pointer.ID]bool
// Reform candidate: after a pair broke, the survivor is still grabbed
// and a NEW finger has landed. The pair re-forms (survivor + new) only
// when the new finger moves (pinchMoveEps) — the same two-mover rule;
// a palm landing on the survivor is not a pinch. reformPos is where
// the candidate PRESSED (the mover reference).
reformID pointer.ID
reformPos f32.Point
reformBase float32 // pair distance at the candidate's press
reformHave bool
// The active (grabbed) pair.
pair [2]pointer.ID
pos [2]f32.Point
on bool
prevDist float32
// The pair's distance at the moment of (re)formation: the distance of
// the two press positions. The Android driver replays
// historical samples, so drags of the forming pair can land in the same
// frame as the pair's formation; movement relative to the formation
// distance is real and owed.
formDist float32
// Set on the pair's formation frame (start or re-form): the baseline
// is being established, so factor() emits nothing unless the pair
// moved from its formation positions this frame.
fresh bool
// The pair broke (a finger lifted) during the current frame's drain.
// factor() runs AFTER the drain, so on a slow frame the pair's drags
// and the breaking release arrive together and the frame's factor
// would be lost (on==false by the time factor() runs). The factor is
// settled here instead.
brokeFactor float32
brokeMid f32.Point
// A pair finger was released while the other is still down (still
// grabbed by the probe; Gio v0.10 has no release-grab). The survivor's
// drags are forwarded as scroll so the finger is not dead.
survOn bool
survID pointer.ID
survPos f32.Point
// Accumulated survivor scroll for the current frame (window px,
// gesture.Scroll convention: positive = content scrolls up).
survScroll int
}
// pinchStep is the outcome of one pointer event.
type pinchStep struct {
// Grabs the adapter must issue (pointer.GrabCmd per ID). Only the
// survivor re-form path emits grabs per event; initial pair formation
// is decided in factor() (after the whole frame's events).
grabs []pointer.ID
}
// step feeds one pointer event into the tracker. The per-frame factor is not
// part of the step result: the adapter calls factor() once after draining
// the frame's events, because the Android driver replays historical samples
// (several drags per frame) and the font must see one factor per frame.
func (t *pinchTracker) step(pe pointer.Event) pinchStep {
var s pinchStep
id := pe.PointerID
switch pe.Kind {
case pointer.Press:
switch {
case t.on:
// Extra finger during an active pinch (palm rest, third
// finger): ignore — the pair is fixed.
case t.survOn && t.reformHave && id == t.reformID:
// The reform candidate lifted before moving: not a pinch.
t.reformHave = false
case t.survOn:
// A new finger lands while the survivor is down: CANDIDATE for
// a re-form; it must MOVE to become the pair (the same two-mover
// rule — a palm resting on the survivor is not a pinch). The
// formation baseline is the distance at this PRESS (any spread
// by re-form time is owed).
t.reformID = id
t.reformPos = pe.Position
t.reformBase = pairDist(t.survPos, pe.Position)
t.reformHave = true
default:
if t.observed == nil {
t.observed = make(map[pointer.ID]f32.Point)
t.observedAt = make(map[pointer.ID]time.Duration)
t.pressPos = make(map[pointer.ID]f32.Point)
}
t.observed[id] = pe.Position
t.observedAt[id] = pe.Time
t.pressPos[id] = pe.Position
// Prune stale fingers (resting palm/pinky); a pruned palm is
// out of the game entirely.
t.pruneStale(pe.Time)
// (Two or more fingers down is the PENDING state; the pair
// forms only when two of them are movers, decided in factor().)
}
case pointer.Drag:
switch {
case t.on:
if i := t.pairIndex(id); i >= 0 {
t.pos[i] = pe.Position
}
case t.survOn && id == t.survID:
t.survScroll += int(t.survPos.Y - pe.Position.Y)
t.survPos = pe.Position
case t.survOn && t.reformHave && id == t.reformID:
// The reform candidate moves: if it clears the epsilon from
// where it pressed it is a real second finger — re-form the
// pair (survivor + new).
dx, dy := pe.Position.X-t.reformPos.X, pe.Position.Y-t.reformPos.Y
if dx*dx+dy*dy > pinchMoveEps*pinchMoveEps {
t.pair = [2]pointer.ID{t.survID, id}
t.pos = [2]f32.Point{t.survPos, pe.Position}
t.survOn = false
t.reformHave = false
t.on = true
t.fresh = true
t.formDist = t.reformBase
s.grabs = []pointer.ID{id} // survivor already grabbed
}
default:
if _, ok := t.pressPos[id]; ok {
t.observed[id] = pe.Position
// Formation is NOT decided here: a per-event decision would
// lock in the first mover pair seen (e.g. (finger, drifting
// palm)) before the second pinch finger's drag lands in the
// same drain. factor() decides, after the full frame.
}
}
case pointer.Release, pointer.Cancel:
switch {
case t.on:
if i := t.pairIndex(id); i >= 0 {
t.breakPair(i)
}
case t.survOn && id == t.survID:
// The survivor lifts too: fully idle (the candidate, if any,
// was a lone finger — never a pair).
t.survOn = false
t.reformHave = false
default:
// Pending finger goes up: held LAZY until factor() — on a slow
// frame the whole pinch (drags AND releases) can land in one
// drain, and the pair must form at the fingers' final positions
// before the break settles the owed factor.
if _, ok := t.observed[id]; ok {
t.markReleased(id)
}
}
case pointer.Leave:
// Only meaningful pre-pinch: a finger that leaves the editor
// region is no longer a pinch candidate. (A grabbed pair finger
// keeps delivering through the grab; its Leave is ignored.)
if t.survOn && t.reformHave && id == t.reformID {
t.reformHave = false
} else if t.observed != nil {
if _, ok := t.observed[id]; ok {
t.markReleased(id)
}
}
}
return s
}
// breakPair settles the frame's owed factor (if any) and breaks the pair.
// The surviving finger stays grabbed by the probe (no release-grab in v0.10)
// and becomes the survivor — UNLESS it too released in this same drain
// (slow frame: a born-and-dead pair), in which case the tracker goes fully
// idle. Settling here (before factor() runs) is what keeps the factor on a
// frame where the drags and the breaking release arrive together.
func (t *pinchTracker) breakPair(i int) {
base := t.prevDist
if base == 0 && t.fresh {
base = t.formDist
}
if d := pairDist(t.pos[0], t.pos[1]); d > 0 && base > 0 && d != base {
if f := d / base; f >= 0.1 && f <= 10 {
t.brokeFactor = f
t.brokeMid = pairMid(t.pos[0], t.pos[1])
}
}
t.on = false
t.prevDist = 0
other := 1 - i
if !t.released[t.pair[other]] {
t.survOn = true
t.survID = t.pair[other]
t.survPos = t.pos[other]
}
}
// markReleased lazily records a pending finger's release (see the Release
// case). Cleared by factor() after it has formed (and possibly broken) the
// pair.
func (t *pinchTracker) markReleased(id pointer.ID) {
if t.released == nil {
t.released = make(map[pointer.ID]bool)
}
t.released[id] = true
}
// dropObserved removes a pending finger for good.
func (t *pinchTracker) dropObserved(id pointer.ID) {
if t.observed == nil {
return
}
delete(t.observed, id)
delete(t.observedAt, id)
delete(t.pressPos, id)
delete(t.released, id)
}
// movers returns the pending fingers that moved more than pinchMoveEps from
// where they pressed, most-displaced first.
func (t *pinchTracker) movers() []pointer.ID {
type dm struct {
id pointer.ID
d float64
}
var list []dm
for id, pp := range t.pressPos {
dx := float64(t.observed[id].X - pp.X)
dy := float64(t.observed[id].Y - pp.Y)
if d := math.Sqrt(dx*dx + dy*dy); d > pinchMoveEps {
list = append(list, dm{id, d})
}
}
for i := 0; i < len(list); i++ {
for j := i + 1; j < len(list); j++ {
if list[j].d > list[i].d {
list[i], list[j] = list[j], list[i]
}
}
}
out := make([]pointer.ID, 0, len(list))
for _, e := range list {
out = append(out, e.id)
}
return out
}
// tryFormPair forms the pair from the pending fingers if at least TWO are
// movers (two moving fingers = a pinch; one = a scroll, never a pair). Of
// the mover pairs, the one whose DISTANCE changed most wins: a pinch's pair
// distance changes, while a drifting palm's distance to a finger changes
// little. A mover pair whose distance does not change (fingers moving in
// unison) is a slide, not a pinch. Returns the IDs to grab, or nil when no
// pair forms.
func (t *pinchTracker) tryFormPairAny() []pointer.ID {
m := t.movers()
if len(m) < 2 {
return nil
}
bestA, bestB := m[0], m[1]
bestChange := -1.0
for i := 0; i < len(m); i++ {
for j := i + 1; j < len(m); j++ {
a, b := m[i], m[j]
now := float64(pairDist(t.observed[a], t.observed[b]))
press := float64(pairDist(t.pressPos[a], t.pressPos[b]))
if chg := math.Abs(now - press); chg > bestChange {
bestChange = chg
bestA, bestB = a, b
}
}
}
if bestChange <= pinchMoveEps {
return nil // unison movement: a slide, not a pinch
}
a, b := bestA, bestB
t.pair = [2]pointer.ID{a, b}
t.pos = [2]f32.Point{t.observed[a], t.observed[b]}
t.on = true
t.fresh = true
// Baseline = the PRESS distance of the pair: any spread that happened
// before the pair started is owed, not lost.
t.formDist = pairDist(t.pressPos[a], t.pressPos[b])
t.observed = nil
t.observedAt = nil
t.pressPos = nil
return []pointer.ID{a, b}
}
// pairIndex returns 0/1 if id is a member of the active pair, else -1.
func (t *pinchTracker) pairIndex(id pointer.ID) int {
if !t.on {
return -1
}
if t.pair[0] == id {
return 0
}
if t.pair[1] == id {
return 1
}
return -1
}
// pruneStale drops observed fingers that pressed more than
// pinchFreshWindow before now.
func (t *pinchTracker) pruneStale(now time.Duration) {
for id, at := range t.observedAt {
if now-at > pinchFreshWindow {
delete(t.observed, id)
delete(t.observedAt, id)
delete(t.pressPos, id)
}
}
}
// factor returns the current frame's relative pinch scale and the pair
// midpoint (window px), or ok=false when no factor applies (no active pair,
// first frame of a pinch, or an implausible jump). It must be called once
// per frame, after all of the frame's events have been stepped.
func (t *pinchTracker) factor() (f float32, mid f32.Point, ok bool, grabs []pointer.ID) {
// Pending pair formation is decided HERE, after the whole frame's
// events (not per event): a per-event decision would lock in the first
// mover pair seen — e.g. (finger, drifting palm) — before the second
// pinch finger's drag lands in the same drain.
if !t.on && !t.survOn && t.observed != nil {
if g := t.tryFormPairAny(); g != nil {
grabs = g
// Slow frame: a pair member may have released in this same
// drain (the release arrived while the pair was still pending):
// break it now at the fingers' final positions.
for i, id := range t.pair {
if t.released[id] {
t.breakPair(i)
break
}
}
}
}
// Pending fingers that released this frame are dropped for good.
if t.released != nil {
for id := range t.released {
t.dropObserved(id)
}
t.released = nil
}
if !t.on {
// The pair broke during this frame's drain: emit the settled
// factor (on a slow frame the drags and the breaking release can
// arrive together, and factor() runs only after the drain).
if t.brokeFactor > 0 {
f, mid, ok = t.brokeFactor, t.brokeMid, true
t.brokeFactor = 0
t.brokeMid = f32.Point{}
}
return f, mid, ok, grabs
}
d := pairDist(t.pos[0], t.pos[1])
if t.fresh {
// Formation frame: establish the baseline. Emit only if the pair
// already moved from its formation positions this frame (the
// Android driver replays historical samples, so drags can batch
// with the formation Press).
t.fresh = false
t.prevDist = d
if t.formDist > 0 && d != t.formDist {
f = d / t.formDist
if f < 0.1 || f > 10 {
return 0, f32.Point{}, false, grabs
}
return f, pairMid(t.pos[0], t.pos[1]), true, grabs
}
return 0, f32.Point{}, false, grabs
}
if t.prevDist > 0 && d > 0 && d != t.prevDist {
// No-movement guard: a stationary pair emits nothing (an f=1.0
// factor would churn the font pin's re-layout for no change).
f = d / t.prevDist
// Sanitize: a real pinch moves millimeters between frames; a
// factor this far off 1 is noise, not a finger.
if f < 0.1 || f > 10 {
t.prevDist = d
return 0, f32.Point{}, false, grabs
}
t.prevDist = d
return f, pairMid(t.pos[0], t.pos[1]), true, grabs
}
t.prevDist = d
return 0, f32.Point{}, false, grabs
}
// survivorScroll returns and resets the frame's accumulated survivor-finger
// scroll (window px, gesture.Scroll convention: positive = content scrolls
// up).
func (t *pinchTracker) survivorScroll() int {
d := t.survScroll
t.survScroll = 0
return d
}
// reset clears all state (the editor left the screen).
func (t *pinchTracker) reset() {
*t = pinchTracker{}
}
// pairDist is the distance between two pair points (window px).
func pairDist(a, b f32.Point) float32 {
dx, dy := a.X-b.X, a.Y-b.Y
return float32(math.Sqrt(float64(dx*dx + dy*dy)))
}
// pairMid is the midpoint of two pair points (window px).
func pairMid(a, b f32.Point) f32.Point {
return f32.Point{X: (a.X + b.X) / 2, Y: (a.Y + b.Y) / 2}
}

View File

@ -0,0 +1,281 @@
package ui
// Definitive host test: run the app's REAL Renderer.Draw op stream (a
// realistic editor frame: status bar, editor TextField, bottom bar) through
// the real input.Router with the app's per-frame protocol (drain before
// commit), queue a touch press inside the editor text, and check which tags
// receive it: the scroll tag (known-good on the phone) as control, plus the
// pressProbe/pinchProbe tags (dead on the phone).
import (
"image"
"testing"
"time"
"gioui.org/f32"
"gioui.org/font/gofont"
"gioui.org/io/event"
"gioui.org/io/input"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
)
func TestRealDrawOpsProbeHit(t *testing.T) {
shp := text.NewShaper(text.WithCollection(gofont.Collection()))
r := New(Theme{FontSize: 14}, shp)
noop := func(any) {}
const (
wW = 411
wH = 914
)
editorRegion := Region{X: 10, Y: 52, W: wW - 20, H: 700}
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line", editorRegion, true, editorRegion.W, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: noop},
{Gesture: Tap, Handler: noop},
{Gesture: Pinch, Handler: noop},
})
editor.Focused = true
editor.ShowIMESeq = 7
statusBar := NewContainer(Region{X: 0, Y: 0, W: wW, H: 52}, Color{R: 240, G: 240, B: 240, A: 255}, []Element{
NewLabel("←", 16, Region{X: 5, Y: 5, W: 40, H: 42}, AlignStart, "back_id", nil),
NewLabel("/storage/emulated/0/Notes/test.txt", 14, Region{X: 50, Y: 5, W: 300, H: 42}, AlignStart, "path_id", nil),
})
bottomBar := NewContainer(Region{X: 0, Y: wH - 40, W: wW, H: 40}, Color{R: 240, G: 240, B: 240, A: 255}, []Element{
NewLabel("Saved", 14, Region{X: 5, Y: wH - 35, W: 100, H: 30}, AlignStart, "saved_id", nil),
})
elems := []Element{statusBar, editor, bottomBar}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: wW, Y: wH}},
}
}
var rtr input.Router
probeK := pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel
pinchF := pointer.Filter{Target: event.Tag(r.pinchProbe), Kinds: probeK}
pressF := pointer.Filter{Target: event.Tag(r.pressProbe), Kinds: probeK}
// Frame 1: record the real app ops and commit.
{
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
if _, ok := r.scrolls["editor_text"]; !ok {
t.Fatal("scroll reg not created for editor_text")
}
scroll := r.scrolls["editor_text"].scroll
t.Logf("scroll tag = %p", scroll)
// Pre-merge filters (app calls Event every frame; first call here).
rtr.Source().Event(pinchF)
rtr.Source().Event(pressF)
rtr.Source().Event(pointer.Filter{Target: scroll, Kinds: probeK})
rtr.Frame(&ops)
}
// A touch press lands in the editor text region.
pos := f32.Pt(100, 100)
rtr.Queue(pointer.Event{Kind: pointer.Press, Source: pointer.Touch, Position: pos})
t.Logf("queued press at %v", pos)
// Frame 2: draw again, then drain (app protocol: consume before commit).
{
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
drain := func(name string, f pointer.Filter) {
for {
e, ok := rtr.Source().Event(f)
if !ok {
break
}
if pe, ok := e.(pointer.Event); ok {
t.Logf("frame2: %-12s got kind=%v pos=%v", name, pe.Kind, pe.Position)
}
}
}
drain("pressProbe", pressF)
drain("pinchProbe", pinchF)
drain("scroll", pointer.Filter{Target: r.scrolls["editor_text"].scroll, Kinds: probeK})
rtr.Frame(&ops)
}
}
// TestRealDrawPinchGrabLifecycle runs the REAL app frame loop (draw ops ->
// CheckGestures -> commit) through the real input.Router with a real
// two-finger pinch and verifies the grab semantics that fix the on-device
// jank: the pair is tracked exclusively (the probe keeps getting drags even
// off-clip, scroll gets nothing), a factor is emitted per moved frame, a
// release breaks the pair, and the survivor finger's drags come out as plain
// scroll (forwarded), so the finger is not dead after a pinch.
func TestRealDrawPinchGrabLifecycle(t *testing.T) {
shp := text.NewShaper(text.WithCollection(gofont.Collection()))
r := New(Theme{FontSize: 14}, shp)
var pinchEvents []any
var scrollEvents []any
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line",
Region{X: 10, Y: 52, W: 391, H: 700}, true, 391, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: func(d any) { scrollEvents = append(scrollEvents, d) }},
{Gesture: Tap, Handler: func(any) {}},
{Gesture: Pinch, Handler: func(d any) { pinchEvents = append(pinchEvents, d) }},
})
editor.Focused = true
elems := []Element{editor}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: 411, Y: 914}},
}
}
var rtr input.Router
var prevOps op.Ops
havePrev := false
// ptEv builds a router-queueable pointer event. "Drag" is written as
// Move: the router only accepts Press/Move/Release/Cancel/Scroll and
// converts a pressed pointer's Move into a Drag before delivery.
ptEv := func(kind pointer.Kind, id pointer.ID, x, y float32, at time.Duration) pointer.Event {
if kind == pointer.Drag {
kind = pointer.Move
}
return pointer.Event{Kind: kind, Source: pointer.Touch, PointerID: id, Position: f32.Point{X: x, Y: y}, Time: at}
}
// runFrame mirrors the app's loop: commit the previous frame's ops
// (w.Event), queue this frame's pointer events, draw, CheckGestures.
// It also observes what the SCROLL tag receives this frame (drained
// before CheckGestures so the observation is lossless).
runFrame := func(evts ...pointer.Event) (events []InputEvent, scrollKinds []pointer.Kind) {
if havePrev {
rtr.Frame(&prevOps)
}
for _, e := range evts {
rtr.Queue(e)
}
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
for {
e, ok := rtr.Source().Event(pointer.Filter{
Target: r.scrolls["editor_text"].scroll,
Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel,
})
if !ok {
break
}
if pe, ok := e.(pointer.Event); ok {
scrollKinds = append(scrollKinds, pe.Kind)
}
}
events = r.CheckGestures(rtr.Source(), m)
// The app's main loop dispatches each event to its logic handler;
// mirror that so the capture handlers see them.
for _, e := range events {
e.Handler(e.Data)
}
prevOps, havePrev = ops, true
return events, scrollKinds
}
// Setup frame: register the ops.
runFrame()
hasKind := func(kinds []pointer.Kind, k pointer.Kind) bool {
for _, x := range kinds {
if x == k {
return true
}
}
return false
}
// Two fingers press 200px apart inside the editor text. Pending: no
// pair yet (the pair forms when BOTH move), no pinch events, no grabs.
_, scrollKinds := runFrame(
ptEv(pointer.Press, 0, 100, 100, time.Millisecond),
ptEv(pointer.Press, 1, 300, 100, 2*time.Millisecond))
if len(pinchEvents) != 0 {
t.Fatalf("press frame emitted a pinch event: %v", pinchEvents)
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw a drag on the press frame: %v", scrollKinds)
}
// The pair spreads 200 -> 240: this is the FORMATION frame — both
// fingers moved (the two-mover rule), the pair forms and the grabs are
// issued. One factor (1.2) is owed against the press distance. The
// pair's drags of THIS frame still reach scroll (the grabs commit on
// the next frame): a one-frame leak bounded by the scroll slop — the
// cost of not grabbing on press (which would kill single-finger
// scrolls). From the NEXT frame on, scroll must see nothing of the pair.
runFrame(
ptEv(pointer.Drag, 0, 80, 100, 10*time.Millisecond),
ptEv(pointer.Drag, 1, 320, 100, 11*time.Millisecond))
if len(pinchEvents) != 1 {
t.Fatalf("formation frame: pinch events=%d want 1 (%v)", len(pinchEvents), pinchEvents)
}
fpe, ok := pinchEvents[0].(FontPinchEvent)
if !ok || fpe.Scale < 1.19 || fpe.Scale > 1.21 {
t.Fatalf("scale=%v want ~1.2", pinchEvents[0])
}
// The pair keeps spreading: from here the grabs are active and SCROLL
// sees no drag of the pair (a Cancel for the dropped press may arrive).
_, scrollKinds = runFrame(
ptEv(pointer.Drag, 0, 50, 100, 15*time.Millisecond),
ptEv(pointer.Drag, 1, 350, 100, 16*time.Millisecond))
if len(pinchEvents) != 2 {
t.Fatalf("mid-pinch frame: pinch events=%d want 2 (%v)", len(pinchEvents), pinchEvents)
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw the pair's drags after formation: %v (grab failed)", scrollKinds)
}
// Finger 0 drags FAR OUTSIDE the editor region: the grab keeps it
// delivering to the probe (no stale pointer, no lost release).
_, scrollKinds = runFrame(
ptEv(pointer.Drag, 0, 2, 890, 20*time.Millisecond), // off-clip
ptEv(pointer.Drag, 1, 330, 100, 21*time.Millisecond))
if len(pinchEvents) != 3 {
t.Fatalf("off-clip frame: pinch events=%d want 3 (pair must survive off-clip)", len(pinchEvents))
}
if hasKind(scrollKinds, pointer.Drag) {
t.Fatalf("scroll saw the off-clip drag: %v", scrollKinds)
}
// Finger 0 lifts (off-clip): the release still arrives via the grab.
// The pair breaks; no pinch event.
before := len(pinchEvents)
runFrame(ptEv(pointer.Release, 0, 2, 890, 30*time.Millisecond))
if len(pinchEvents) != before {
t.Fatal("broken pair emitted a pinch event")
}
// The survivor (finger 1) scrolls 30px up: forwarded as a plain scroll
// delta to the editor's scroll handler — the finger is not dead.
scrollBefore := len(scrollEvents)
runFrame(ptEv(pointer.Drag, 1, 330, 70, 40*time.Millisecond))
if len(scrollEvents) != scrollBefore+1 {
t.Fatalf("survivor scroll not forwarded: events=%d want %d", len(scrollEvents), scrollBefore+1)
}
if d, ok := scrollEvents[scrollBefore].(int); !ok || d != 30 {
t.Fatalf("survivor delta=%v want 30 (px, scroll-up positive)", scrollEvents[scrollBefore])
}
// A second finger returns: candidate for a re-form (no event on the
// press); it must MOVE to become the pair (two-mover rule).
runFrame(ptEv(pointer.Press, 2, 50, 70, 50*time.Millisecond))
before = len(pinchEvents)
runFrame(
ptEv(pointer.Drag, 1, 340, 70, 60*time.Millisecond),
ptEv(pointer.Drag, 2, 30, 70, 61*time.Millisecond)) // 20px from press
if len(pinchEvents) != before+1 {
t.Fatalf("re-formed pair: pinch events=%d want %d", len(pinchEvents), before+1)
}
}

View File

@ -62,6 +62,17 @@ type scrollReg struct {
handler func(any)
}
// Probe tags for the raw-pointer probes (long-press, pinch). Each probe
// needs its OWN named type: the unnamed fieldless struct{} is a single
// canonical Go type, so distinct `struct{}` fields are the SAME value.
// Gio's router keys handlers by the tag value, so two `struct{}` probes
// collapse into one handler and whichever probe drains first (the press
// probe, which is consumed before the pinch probe) consumes every event,
// starving the other — this is why pinch received nothing on device while
// long-press appeared to work.
type pressProbeTag struct{}
type pinchProbeTag struct{}
// Renderer consumes a slice of elements and draws them.
//
// The Renderer is owned by the main goroutine. It is the home of any state
@ -80,6 +91,17 @@ type Renderer struct {
lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout)
glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText
// ZeroWheelScroll, set by main on a frame whose window size shrank, makes
// CheckGestures drain any pointer.Scroll events queued for the editor's
// scroll gesture before consuming gestures. Gio's window calls RevealFocus
// on any frame the viewport shrinks (e.g. the IME opening under
// adjustResize) and synthesizes a pointer.Scroll nudge to bring the focused
// field's (stale, pre-resize) bounds into view; consumed by gesture.Scroll
// that nudge shifts the editor content. The drain kills only the
// synthesized event: finger scroll (pointer.Drag) and the flinger are
// untouched, and normal frames consume pointer.Scroll as before.
ZeroWheelScroll bool
// IME dedup (main-owned, persistent across frames). Re-pushing an unchanged
// snippet or selection every frame resets the IME's composition and caret,
// which desyncs fast commits; push only on change, as widget.Editor does in
@ -114,12 +136,40 @@ type Renderer struct {
// 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{}
pressProbe pressProbeTag
ppLast f32.Point
ppActive bool
ppMoved bool
longPressID string
// Pinch-to-change-font-size (editor text region). Gio v0.10 has no
// two-finger pinch primitive: pinchProbe is a raw event tag inside the
// editor clip, and pinchT (pinch_tracker.go) is the gesture's state
// machine. When two fresh fingers are down the tracker names them as an
// EXPLICIT pair and the adapter grabs both (pointer.GrabCmd): exclusive
// event delivery (releases always arrive, even off-clip; scroll/click
// are dropped with a Cancel, which also stops the first finger dragging
// the text mid-pinch). The per-frame factor is the pair-distance ratio
// (FontPinchEvent to pinchHandler). The pair is never re-derived from
// whatever pointers happen to be present: that re-derivation (two
// lowest IDs) let a resting third finger pair with a live one and made
// single-finger scrolls scale the font on the phone. When one pair
// finger lifts, the survivor stays grabbed (v0.10 has no release-grab)
// and its drags are forwarded as scroll via pinchScrollHandler. State
// is dropped when the editor leaves the screen (see Draw).
pinchProbe pinchProbeTag
pinchT pinchTracker
pinchHandler func(any)
pinchScrollHandler func(any)
pinchProbeOn bool
// appFontScale is the app-local font-size multiplier (pinch zoom;
// 1.0 = default, 0 = not set yet). The main goroutine feeds it from the
// frame's snapshot via SetAppFontScale before Draw; drawWrappedText
// multiplies the editor font size by it. The system user font scale is
// separate and already folded into gtx.Metric/gtx.Sp.
appFontScale float32
// 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
@ -155,6 +205,17 @@ type Renderer struct {
gestureExclusions [][4]int
}
// SetAppFontScale sets the app-local font-size multiplier for subsequent
// draw passes (1.0 = default; <= 0 is treated as 1). Main-goroutine-only;
// call before Draw (see appFontScale).
func (r *Renderer) SetAppFontScale(v float32) {
if v > 0 {
r.appFontScale = v
} else {
r.appFontScale = 1
}
}
// GestureExclusions returns the handle grab boxes collected for the last
// frame (see gestureExclusions).
func (r *Renderer) GestureExclusions() [][4]int { return r.gestureExclusions }
@ -238,6 +299,12 @@ func (r *Renderer) toDp(px Px) Dp {
func (r *Renderer) Draw(gtx layout.Context, elems []Element, scale float32) {
r.scale = scale
r.focusSeenThisFrame = false // per-frame reset for FocusCmd dedup
if !r.pinchProbeOn {
// No editor text in the previous frame: drop all pinch state so a
// later pinch starts clean.
r.pinchT.reset()
}
r.pinchProbeOn = false
// Gio sets constraints to layout.Exact(windowSize), so Min==Max. Use (0,0) as Min.
winW := gtx.Constraints.Max.X
winH := gtx.Constraints.Max.Y
@ -343,6 +410,12 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
// Long-press motion probe first: it must see the press/drag events before
// the click loop decides about a long press.
r.consumePressProbe(q)
// Pinch probe: drain the raw pointer events, grab/release the pair,
// and emit the frame's scale factor and any survivor-finger scroll.
// Runs before the click/scroll loops so the pinch is applied in the
// same input batch that carried the finger moves, and the pair's
// grabs are queued ahead of any competing scroll grab.
events = append(events, r.consumePinchProbe(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
@ -467,9 +540,25 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
// gesture.Scroll.Update returns scroll delta in pixels.
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
// With Min==Max==0, clampSplit consumes zero scroll.
// Update runs unconditionally (it keeps the gesture's flinger state
// healthy). Emission is suppressed while a pinch owns the pair: the
// pair is grabbed (scroll is dropped from its path) once the grabs
// commit, and this guard covers the one-frame window before that.
if r.ZeroWheelScroll {
// RevealFocus (see ZeroWheelScroll) queued a synthetic
// pointer.Scroll on this shrink frame; consume it here so the
// gesture never sees it. Scroll-range clamping is no use: the
// router UNIONs ranges across frames and the historical max
// can never shrink back to zero.
for {
if _, ok := q.Event(pointer.Filter{Target: reg.scroll, Kinds: pointer.Scroll}); !ok {
break
}
}
}
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
if delta != 0 {
if delta != 0 && !r.pinchT.on {
events = append(events, InputEvent{
Handler: reg.handler,
Data: delta,
@ -480,11 +569,63 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
return events
}
// consumePinchProbe drains the pinch probe's raw pointer events into the
// tracker, issues the pair's grabs, and returns this frame's events: the
// scale factor (when the active pair moved) and the survivor finger's
// forwarded scroll (when a pair broke with one finger still down).
func (r *Renderer) consumePinchProbe(q input.Source) []InputEvent {
var events []InputEvent
for {
evt, ok := q.Event(pointer.Filter{Target: r.pinchProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
if !ok {
break
}
pe, ok := evt.(pointer.Event)
if !ok {
continue
}
if s := r.pinchT.step(pe); len(s.grabs) > 0 {
for _, id := range s.grabs {
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
}
}
}
// The tracker may have formed the pair during factor() (after the full
// frame's events); issue those grabs now. Either way they commit
// before any scroll grab queued later in this frame (FIFO command
// queue), so the pair wins the race even if a finger is already past
// the scroll slop.
f, mid, ok, grabs := r.pinchT.factor()
for _, id := range grabs {
q.Execute(pointer.GrabCmd{Tag: r.pinchProbe, ID: id})
}
if ok && r.pinchHandler != nil {
events = append(events, InputEvent{
Handler: r.pinchHandler,
Data: FontPinchEvent{
Scale: f,
Center: Point{X: r.toDp(Px(mid.X)), Y: r.toDp(Px(mid.Y))},
},
})
}
if d := r.pinchT.survivorScroll(); d != 0 && r.pinchScrollHandler != nil {
events = append(events, InputEvent{
Handler: r.pinchScrollHandler,
Data: d,
})
}
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.
// Leave ends the pending press: a finger that drifts off the editor region
// must not keep a long press armed (its release would never come to the
// probe if it was grabbed by scroll, so without this the timer could fire
// for a finger that is long gone).
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})
evt, ok := q.Event(pointer.Filter{Target: r.pressProbe, Kinds: pointer.Press | pointer.Drag | pointer.Release | pointer.Cancel | pointer.Leave})
if !ok {
return
}
@ -505,7 +646,7 @@ func (r *Renderer) consumePressProbe(q input.Source) {
}
}
r.ppLast = pe.Position
case pointer.Release, pointer.Cancel:
case pointer.Release, pointer.Cancel, pointer.Leave:
r.ppMoved = false
r.ppActive = false
}
@ -595,6 +736,18 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
// clipped in drawWrappedText where the handle geometry is known.
r.registerInteraction(interactive.ID(), interaction, gtx)
}
if interaction.Gesture == Pinch {
// The renderer owns the probe (raw two-pointer geometry);
// this records the logic handlers. The scroll handler is
// the survivor-finger's forwarding target: after a pinch
// breaks with one finger still down, that finger stays
// grabbed by the probe (v0.10 has no release-grab), so its
// drags are emitted as plain scroll deltas.
r.pinchHandler = interaction.Handler
if reg, ok := r.scrolls[interactive.ID()]; ok {
r.pinchScrollHandler = reg.handler
}
}
}
}
e.Draw(gtx, r)
@ -783,19 +936,28 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
}
r.gestureExclusions = nil // rebuilt from this frame's handle boxes
// App-local font-size multiplier (pinch zoom; SetAppFontScale keeps it
// > 0). It multiplies the sp font size directly, so the value is a
// continuous float — no rounding to whole points anywhere.
appScale := r.appFontScale
if appScale <= 0 {
appScale = 1
}
size := float32(r.theme.FontSize) * appScale
// Fixed line height based on font size, not glyph metrics.
lineHeightSp := unit.Sp(float32(r.theme.FontSize) * LineHeightScale)
lineHeightSp := unit.Sp(size * LineHeightScale)
// User font-size setting (sp per dp). The shaper draws baselines at
// Sp(...) physical px, so the RENDERED line pitch in density-dp is
// lineHeightSp × fontScale. Every dp-space value below (line height,
// ascent) uses the scaled form so caret/handles/highlight follow the
// drawn glyphs; the logic side tracks the same factor via
// EffectiveLineHeight (ScaleEvent.FontScale).
// EffectiveLineHeight (ScaleEvent.FontScale × app font scale).
fontScale := float32(1)
if gtx.Metric.PxPerDp > 0 && gtx.Metric.PxPerSp > 0 {
fontScale = gtx.Metric.PxPerSp / gtx.Metric.PxPerDp
}
ascent := Dp(float32(r.theme.FontSize) * fontScale)
ascent := Dp(size * fontScale)
lineH := Dp(float32(lineHeightSp) * fontScale)
// Wrap disabled: shape with unlimited width so lines extend past the
// region (clipped by textClip below) instead of wrapping.
@ -804,7 +966,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
maxWidthPx = int(r.toPx(wrapWidth))
}
params := text.Parameters{
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
PxPerEm: fixed.I(gtx.Sp(unit.Sp(size))),
MinWidth: 0,
MaxWidth: maxWidthPx,
MaxLines: 0, // unlimited - wrap at MaxWidth
@ -952,6 +1114,10 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
// inside the editor region.
r.selDragsOn = [4]bool{}
event.Op(gtx.Ops, r.pressProbe)
// Pinch probe: same clip as the press probe, so a pinch only registers
// when both fingers' presses/moves land in the editor text region.
event.Op(gtx.Ops, r.pinchProbe)
r.pinchProbeOn = true
r.longPressID = "editor_text"
if r.selDragHandler != nil && (selStart >= 0 && selEnd > selStart || caretDrag) {
// handleAt mirrors the cursor computation above: window-relative byte

View File

@ -0,0 +1,133 @@
package ui
import (
"image"
"testing"
"time"
"gioui.org/font/gofont"
"gioui.org/gesture"
"gioui.org/io/event"
"gioui.org/io/input"
"gioui.org/io/key"
"gioui.org/io/pointer"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/text"
"gioui.org/unit"
)
// TestRevealFocusDrainTermination reproduces the IME-open scenario at the
// router level: the focused editor field has registered (stale, taller)
// bounds, the window's viewport shrinks, and the framework calls
// Router.RevealFocus which synthesizes a pointer.Scroll for the focused
// field's scroll handler. The app's shrink-frame fix (Renderer
// ZeroWheelScroll) drains pointer.Scroll events for the gesture's tag before
// gesture.Scroll.Update consumes them. This test verifies:
// 1. RevealFocus really does queue a scroll event for the gesture tag,
// 2. the drain loop terminates (bounded iteration count),
// 3. after the drain, gesture.Scroll.Update returns 0 (content does not
// move), and
// 4. on a normal (non-shrink) frame the drain is not needed and the
// gesture consumes whatever scroll it would have consumed anyway.
func TestRevealFocusDrainTermination(t *testing.T) {
shp := text.NewShaper(text.WithCollection(gofont.Collection()))
r := New(Theme{FontSize: 14}, shp)
noop := func(any) {}
const (
wW = 411
wH = 914
)
editorRegion := Region{X: 10, Y: 52, W: wW - 20, H: wH - 92}
// KeyDown is required: it is what makes drawElement record the
// event.Op reference for the tag, without which the key queue clears
// the FocusCmd target ("tag has no event.Op references").
editor := NewTextField("editor_text", "hello world\nsecond line\nthird line", editorRegion, true, editorRegion.W, 0, 5, -1, -1, []Interaction{
{Gesture: Scroll, Handler: noop},
{Gesture: KeyDown, Handler: noop},
})
editor.Focused = true
elems := []Element{editor}
m := unit.Metric{PxPerDp: 1, PxPerSp: 1}
var rtr input.Router
gtxFor := func(ops *op.Ops) layout.Context {
return layout.Context{
Ops: ops,
Metric: m,
Source: rtr.Source(),
Constraints: layout.Constraints{Min: image.Point{}, Max: image.Point{X: wW, Y: wH}},
}
}
// The app consumes a key.FocusFilter for the focused field every frame
// (main.go); that is what marks the handler focusable so keyQueue.Frame
// keeps the focus across frames.
drainFocus := func(q input.Source) {
for {
if _, ok := q.Event(key.FocusFilter{Target: event.Tag("editor_text")}); !ok {
break
}
}
}
// Frame 1: register handlers, focus the field (Draw issues the FocusCmd).
{
var ops op.Ops
gtx := gtxFor(&ops)
r.Draw(gtx, elems, 1.0)
drainFocus(gtx.Source)
rtr.Frame(&ops)
}
scroll := r.scrolls["editor_text"].scroll
t.Logf("focused(editor_text) after frame1: %v", rtr.Source().Focused(event.Tag("editor_text")))
// Frame 2: the app consumes gestures every frame; gesture.Scroll.Update
// registers the Scroll kind in the handler's filter, which Router.Deliver
// (used by RevealFocus/ScrollFocus) requires to match.
{
var ops op.Ops
gtx := gtxFor(&ops)
r.Draw(gtx, elems, 1.0)
drainFocus(gtx.Source)
_ = scroll.Update(m, gtx.Source, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
rtr.Frame(&ops)
}
// Simulate the keyboard opening: the framework shrinks the viewport and
// calls RevealFocus with the new (smaller) viewport.
shrunk := image.Rectangle{Min: image.Point{}, Max: image.Point{X: wW, Y: wH - 400}}
rtr.RevealFocus(shrunk)
// The app's shrink-frame fix: drain pointer.Scroll for the gesture tag.
q := rtr.Source()
drained := 0
for {
evt, ok := q.Event(pointer.Filter{Target: scroll, Kinds: pointer.Scroll})
if !ok {
break
}
drained++
if pe, ok := evt.(pointer.Event); ok {
t.Logf("drained synthetic scroll: %+v", pe.Scroll)
}
if drained > 10 {
t.Fatalf("drain loop did not terminate within 10 iterations")
}
}
if drained == 0 {
t.Fatalf("RevealFocus did not queue a scroll event for the scroll gesture tag")
}
// The gesture must now see no scroll (content stays put).
var ops op.Ops
r.Draw(gtxFor(&ops), elems, 1.0)
delta := scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30})
if delta != 0 {
t.Fatalf("gesture consumed %d px of scroll after drain; content would shift", delta)
}
rtr.Frame(&ops)
}

View File

@ -0,0 +1,29 @@
package ui
import (
"testing"
"gioui.org/font/gofont"
"gioui.org/text"
)
// Regression test for the pinch-on-device bug: the probes were declared as
// `struct{}` fields. The unnamed fieldless struct{} is a single canonical Go
// type, so all three probes were the SAME tag value and Gio's router merged
// them into one handler — the press probe (drained first) consumed every
// event and the pinch probe was starved. Each probe now has its own named
// type and must be a distinct map key.
func TestProbeTagIdentity(t *testing.T) {
r := New(Theme{FontSize: 14}, text.NewShaper(text.WithCollection(gofont.Collection())))
m := map[interface{}]int{}
m[r.pressProbe] = 1
m[r.pinchProbe] = 2
t.Logf("distinct probe tag keys: %d", len(m))
if len(m) != 2 {
t.Fatalf("probe tags must be distinct values, got %d distinct keys", len(m))
}
var a, b interface{} = r.pressProbe, r.pinchProbe
if a == b {
t.Fatal("pressProbe and pinchProbe must not be equal interface values")
}
}

View File

@ -91,6 +91,11 @@ type LayoutFeedback struct {
WindowStartByte int // absolute byte offset of the window's first byte
WindowStartLine int // logical line the window starts at (-1: none)
EditSeq uint64 // editor content-edit counter at frame time
// ScrollOffset is the editor scroll offset (Dp) the frame this layout
// was shaped from carried. The logic needs it to express layout positions
// (window-relative) in content coordinates: the window top is the
// shaped scroll's sub-line remainder above the region top.
ScrollOffset Dp
}
type GlyphLayout struct {

View File

@ -363,8 +363,31 @@ new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
if old2 not in t:
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
t = t.replace(old2, new2)
# 3. onStop hook: persist the session snapshot the moment the activity goes
# away (recents-wipe, app switch). The OS provides no user-space hook for
# the process kill that follows, so this is the last reliable flush. Go
# side: pad_flush_session (impl_android.go) -> Logic.FlushSession.
old3 = """.method public onStop()V
.locals 1
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
new3 = """.method public static native padFlushSession()V
.end method
.method public onStop()V
.locals 1
# Pad: persist the session snapshot before the activity goes away.
invoke-static {}, Lorg/gioui/GioActivity;->padFlushSession()V
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
if old3 not in t:
sys.exit("ERROR: GioActivity onStop anchor not found")
t = t.replace(old3, new3)
p.write_text(t)
print("patched: GioActivity IME insets wiring")
print("patched: GioActivity IME insets wiring + onStop session flush")
PYEOF
echo "=== apktool rebuild ==="

View File

@ -31,8 +31,14 @@ command -v apksigner >/dev/null 2>&1 || die "apksigner not found (need $ANDROID_
command -v adb >/dev/null 2>&1 || die "adb not found (need $ANDROID_HOME/platform-tools)"
[ -f "$HOME/.android/debug.keystore" ] || die "debug keystore missing: $HOME/.android/debug.keystore"
echo "=== static checks (go vet + staticcheck) ==="
"$REPO/scripts/check.sh"
# SKIP_CHECK=1 skips the static checks (scripts/release.sh already runs them
# as gate 1); a standalone build always runs them.
if [ "${SKIP_CHECK:-0}" = "1" ]; then
echo "=== static checks (skipped: SKIP_CHECK=1) ==="
else
echo "=== static checks (go vet + staticcheck) ==="
"$REPO/scripts/check.sh"
fi
APKTOOL="$ANDROID_HOME/tools/apktool.jar"
if [ ! -f "$APKTOOL" ]; then
@ -356,8 +362,31 @@ new2 = ".method public onCreate(Landroid/os/Bundle;)V\n .locals 3"
if old2 not in t:
sys.exit("ERROR: GioActivity onCreate .locals anchor not found")
t = t.replace(old2, new2)
# 3. onStop hook: persist the session snapshot the moment the activity goes
# away (recents-wipe, app switch). The OS provides no user-space hook for
# the process kill that follows, so this is the last reliable flush. Go
# side: pad_flush_session (impl_android.go) -> Logic.FlushSession.
old3 = """.method public onStop()V
.locals 1
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
new3 = """.method public static native padFlushSession()V
.end method
.method public onStop()V
.locals 1
# Pad: persist the session snapshot before the activity goes away.
invoke-static {}, Lorg/gioui/GioActivity;->padFlushSession()V
.line 47
iget-object v0, p0, Lorg/gioui/GioActivity;->view:Lorg/gioui/GioView;"""
if old3 not in t:
sys.exit("ERROR: GioActivity onStop anchor not found")
t = t.replace(old3, new3)
p.write_text(t)
print("patched: GioActivity IME insets wiring")
print("patched: GioActivity IME insets wiring + onStop session flush")
PYEOF
echo "=== apktool rebuild ==="

View File

@ -16,7 +16,11 @@
# # first tap right after launch/open is
# # sometimes swallowed — re-tap.
# scripts/emu.sh type TEXT # type into the focused field (spaces ok)
# scripts/emu.sh cmd <top|bottom|frac F|dp N> # one-shot editor debug command
# scripts/emu.sh cmd <top|bottom|frac F|dp N|pinch F|fontsize F>
# # one-shot editor debug command
# # (pinch F = relative app font scale
# # anchored at the region center;
# # fontsize F = absolute, top-anchored)
# scripts/emu.sh perf on|off # enable/disable the in-app profiler
# scripts/emu.sh perf pull [FILE] # pull logic_frames.csv (default ./logic_frames.csv)
# scripts/emu.sh push FILE # push FILE -> /storage/emulated/0/Notes/
@ -30,7 +34,8 @@
# the CSV is truncated on app relaunch, so pull it before restarting the
# app if you are accumulating data.
# - One-shot editor debug commands are read from /storage/emulated/0/PadPerf/cmd
# (scroll: top | bottom | "frac 0.5" | "dp 1234").
# (scroll: top | bottom | "frac 0.5" | "dp 1234"; app font scale:
# "pinch 1.1" relative | "fontsize 1.5" absolute).
# - Test files go under /storage/emulated/0/Notes/.
# - The emulator OOMs above ~2.5 GB RSS on this VM; kill it if it wedges.
#
@ -138,7 +143,7 @@ cmd_up() {
kill -9 "$qp" 2>/dev/null || true
sleep 2
fi
start_emulator
start_emulator "$@"
local rc=0
wait_device_online "$ONLINE_TIMEOUT" || rc=$?
if [ "$rc" -ne 0 ]; then
@ -163,7 +168,7 @@ cmd_up() {
sleep 1
j=$((j + 1))
done
start_emulator -no-snapshot-load
start_emulator -no-snapshot-load "$@"
# Note: full BOOT_TIMEOUT here — a cold boot legitimately takes far
# longer than the ONLINE_TIMEOUT used for the first (snapshot) attempt.
wait_device_online "$BOOT_TIMEOUT" || { tail -n 5 "$EMU_LOG" >&2; die "device never came online (cold boot); see $EMU_LOG"; }

62
scripts/release.sh Executable file
View File

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Pad release: run the release gates, build the phone APK, and install it to
# every connected device.
#
# The full process, policies, and failure handling are documented in
# doc/release.md — this script is the executable form of that document.
#
# Gates (ALL must pass; any failure aborts before the install):
# 1. static checks: go vet + staticcheck (scripts/check.sh)
# 2. go test -count=1 ./... (includes TestNoFramesWhileIdle)
# 3. frame-regression profile on the EMULATOR (scripts/profile_emulator.sh)
#
# Install policy (doc/release.md):
# - The built APK is installed to EVERY connected device — emulators and
# phones. Pushing a release build to the developer's phone is the
# DEFAULT, expected behavior.
# - This script installs ONLY. It never profiles or tests a phone.
# On-device profiling/testing (e.g. scripts/profile_emulator.sh -s
# <phone serial>) is diagnostic and requires explicit developer approval
# per run.
#
# Usage:
# ./scripts/release.sh # gates + build + install to all devices
# ./scripts/release.sh --no-install
set -euo pipefail
REPO=$(cd "$(dirname "$0")/.." && pwd)
INSTALL=1
[ "${1:-}" = "--no-install" ] && INSTALL=0
if [ -n "$(git -C "$REPO" status --porcelain 2>/dev/null)" ]; then
echo "NOTE: working tree is dirty — the gates run on the current (possibly"
echo " uncommitted) state. Commit the release work before shipping."
fi
echo "=== gate 1/3: static checks ==="
"$REPO/scripts/check.sh"
echo "=== gate 2/3: go test (includes TestNoFramesWhileIdle) ==="
(cd "$REPO" && go test -count=1 ./...)
echo "=== gate 3/3: frame-regression profile (emulator only) ==="
"$REPO/scripts/profile_emulator.sh"
echo "=== build phone APK ==="
SKIP_CHECK=1 "$REPO/scripts/build_phone.sh" --no-install
if [ "$INSTALL" = "1" ]; then
echo "=== install to all connected devices ==="
serials=$(adb devices | awk 'NR>1 && $2=="device" {print $1}')
if [ -z "$serials" ]; then
echo "no connected devices — APK built at cmd/pad/pad-phone.apk;"
echo "install later with: adb -s <serial> install -r cmd/pad/pad-phone.apk"
else
for s in $serials; do
echo "-- installing on $s"
adb -s "$s" install -r "$REPO/cmd/pad/pad-phone.apk"
done
fi
fi
echo "=== RELEASE COMPLETE ==="

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="touch.inject">
<uses-permission android:name="android.permission.INJECT_EVENTS"/>
<application>
<service android:name=".Injector" android:exported="true"/>
<receiver android:name=".Injector$ScriptReceiver" android:exported="true"/>
</application>
</manifest>

View File

@ -0,0 +1,223 @@
package touch.inject;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.hardware.input.InputManager;
import android.os.IBinder;
import android.os.SystemClock;
import android.util.Log;
import android.view.MotionEvent;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Multi-touch gesture injector for the Pad emulator.
*
* Runs as a privileged system app (/system/priv-app) so that the
* signature|privileged INJECT_EVENTS permission is granted; it then injects
* real MotionEvents through InputManager the exact path a physical touch
* screen takes which is what the adb `input` applet cannot do (it has no
* multi-touch support).
*
* Usage:
* adb shell am startservice -n touch.inject/.Injector -e script "SCRIPT"
*
* SCRIPT is a whitespace-separated sequence of:
* down <finger> <x> <y> finger (1-based) touches at (x,y) px
* move <finger> <x> <y> finger moves to (x,y) px
* up <finger> finger lifts
* wait <ms> pause
*
* Progress is logged to logcat under the "TouchInject" tag.
*/
public class Injector extends Service {
public static final String TAG = "TouchInject";
/** Shell-triggerable entry point: works even while the app is stopped
* (the shell can broadcast to an explicit component).
* KNOWN FLAKE: the process is "cached" while the thread runs and the
* 1.5GB emulator OOM-killed it once mid-script (during a 200ms wait),
* losing the final UP. Service routing is NOT an alternative Android
* 12+ blocks background startService from a receiver, and the AVD's
* locked bootloader blocks the system-app escalation. The harness
* therefore verifies "=== done" in logcat after every script and
* re-runs on failure; scripts should stay short. */
public static class ScriptReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String script = intent.getStringExtra("script");
if (script == null) {
return;
}
InputManager im = (InputManager)
context.getSystemService(Context.INPUT_SERVICE);
new Thread(() -> execute(im, script), "TouchInject").start();
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
String script = intent != null ? intent.getStringExtra("script") : null;
if (script == null) {
stopSelf();
return START_NOT_STICKY;
}
InputManager im = (InputManager) getSystemService(INPUT_SERVICE);
new Thread(() -> execute(im, script), "TouchInject").start();
return START_NOT_STICKY;
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private static void execute(InputManager im, String script) {
Log.i(TAG, "=== script: " + script);
// finger number (1-based) -> [x, y]; insertion order = pointer index.
LinkedHashMap<Integer, float[]> fingers = new LinkedHashMap<>();
long downTime = 0;
String[] toks = script.split("\\s+");
int i = 0;
while (i < toks.length) {
String cmd = toks[i++];
try {
switch (cmd) {
case "down": {
int f = Integer.parseInt(toks[i++]);
float x = Float.parseFloat(toks[i++]);
float y = Float.parseFloat(toks[i++]);
int action = fingers.isEmpty()
? MotionEvent.ACTION_DOWN
: MotionEvent.ACTION_POINTER_DOWN;
int idx = fingers.size();
fingers.put(f, new float[]{x, y});
inject(im, action, idx, fingers, downTime);
if (downTime == 0) {
downTime = SystemClock.uptimeMillis();
}
break;
}
case "move": {
int f = Integer.parseInt(toks[i++]);
float x = Float.parseFloat(toks[i++]);
float y = Float.parseFloat(toks[i++]);
float[] p = fingers.get(f);
if (p == null) {
Log.w(TAG, "move of unknown finger " + f + " (skipped)");
break;
}
p[0] = x;
p[1] = y;
// Generic ACTION_MOVE for all moves (1 or N pointers):
// InputFlinger accepts it and GioView treats every
// pointer as a MOVE.
inject(im, MotionEvent.ACTION_MOVE, 0, fingers, downTime);
break;
}
case "up": {
int f = Integer.parseInt(toks[i++]);
if (!fingers.containsKey(f)) {
Log.w(TAG, "up of unknown finger " + f + " (skipped)");
break;
}
boolean last = fingers.size() == 1;
int action = last
? MotionEvent.ACTION_UP
: MotionEvent.ACTION_POINTER_UP;
int idx = indexOf(fingers, f);
// The lifted pointer must still be part of the event.
inject(im, action, idx, fingers, downTime);
fingers.remove(f);
if (fingers.isEmpty()) {
downTime = 0;
}
break;
}
case "wait": {
int ms = Integer.parseInt(toks[i++]);
Thread.sleep(ms);
break;
}
default:
Log.e(TAG, "unknown command: " + cmd);
}
} catch (Exception e) {
Log.e(TAG, "script error at '" + cmd + "': " + e);
break;
}
}
Log.i(TAG, "=== done");
}
private static int indexOf(LinkedHashMap<Integer, float[]> m, int f) {
int idx = 0;
for (Integer k : m.keySet()) {
if (k == f) {
return idx;
}
idx++;
}
return 0;
}
// INJECT_INPUT_EVENT_MODE_WAIT_FOR_RESULT. The SDK stub jar omits the
// constant (and injectInputEvent itself), so both go through reflection;
// the method is a public API at runtime on the device.
private static final int INJECT_WAIT_FOR_RESULT = 1;
private static void inject(InputManager im, int action, int actionIndex,
Map<Integer, float[]> fingers, long downTime) {
int n = fingers.size();
int[] ids = new int[n];
MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[n];
int j = 0;
for (Map.Entry<Integer, float[]> e : fingers.entrySet()) {
ids[j] = e.getKey() - 1; // pointer id (0-based)
MotionEvent.PointerCoords c = new MotionEvent.PointerCoords();
c.x = e.getValue()[0];
c.y = e.getValue()[1];
c.pressure = 1f;
c.size = 1f;
// TOOL_TYPE_FINGER. The compile-time android.jar lacks
// PointerCoords.setToolType, but the device runtime (API 24+)
// has it, so call it reflectively.
try {
java.lang.reflect.Method stt = MotionEvent.PointerCoords.class
.getMethod("setToolType", int.class);
stt.invoke(c, 1);
} catch (Exception ignored) {
}
coords[j] = c;
j++;
}
long now = SystemClock.uptimeMillis();
int fullAction = action == MotionEvent.ACTION_MOVE
? action
: action | (actionIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
MotionEvent ev = MotionEvent.obtain(downTime, now, fullAction, n, ids, coords,
0 /*edgeFlags*/, 1f /*xPrecision*/, 1f /*yPrecision*/,
0 /*metaState*/, 0 /*deviceId*/,
0x4000003 /*SOURCE_TOUCH|SOURCE_CLASS_MASK*/, 0 /*displayId*/);
boolean ok = doInject(im, ev);
Log.i(TAG, String.format("inject action=%d idx=%d n=%d ids=%s ok=%b",
action, actionIndex, n, java.util.Arrays.toString(ids), ok));
ev.recycle();
}
private static boolean doInject(InputManager im, MotionEvent ev) {
try {
java.lang.reflect.Method m = InputManager.class.getMethod(
"injectInputEvent", android.view.InputEvent.class, int.class);
Object r = m.invoke(im, ev, INJECT_WAIT_FOR_RESULT);
return r instanceof Boolean && (Boolean) r;
} catch (Exception e) {
Throwable c = e.getCause() != null ? e.getCause() : e;
Log.e(TAG, "injectInputEvent failed: " + c);
return false;
}
}
}

48
tools/touchinject/build.sh Executable file
View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Builds touchinject.apk (a privileged multi-touch gesture injector) from
# Injector.java using the local Android SDK. The APK must be installed under
# /system/priv-app for the INJECT_EVENTS (signature|privileged) permission to
# be granted — see the bottom of this file.
set -euo pipefail
cd "$(dirname "$0")"
SDK=/home/gmp/android-sdk
BT="$SDK/build-tools/35.0.0"
PLAT="$SDK/platforms/android-35/android.jar"
rm -rf build
mkdir -p build/classes
javac -nowarn -source 1.8 -target 1.8 -classpath "$PLAT" -d build/classes Injector.java
"$BT/d8" --release --min-api 24 --lib "$PLAT" --output build build/classes/touch/inject/*.class
# aapt2 resolves the android: namespace against the framework resource
# table, which is shipped inside android.jar (resources.arsc).
"$BT/aapt2" link --manifest AndroidManifest.xml -I "$PLAT" \
--min-sdk-version 24 --target-sdk-version 35 -o build/base.apk
cp build/base.apk build/touchinject-unsigned.apk
jar uf build/touchinject-unsigned.apk -C build classes.dex
if [ ! -f build/ts.jks ]; then
keytool -genkeypair -keystore build/ts.jks -storepass android -keypass android \
-alias ts -dname "CN=TouchInject, OU=Dev" -keyalg RSA -keysize 2048 -validity 10000
fi
"$BT/apksigner" sign --ks build/ts.jks --ks-pass pass:android --key-pass pass:android \
--out touchinject.apk build/touchinject-unsigned.apk
"$BT/apksigner" verify --print-certs touchinject.apk | head -1
echo "=== DONE: $(pwd)/touchinject.apk"
# --- Install as a privileged system app (emulator, rooted) ---------------
# SER=emulator-5554
# adb -s $SER root && adb -s $SER remount
# adb -s $SER shell mkdir -p /system/priv-app/TouchInject
# adb -s $SER push touchinject.apk /system/priv-app/TouchInject/TouchInject.apk
# adb -s $SER shell chmod 644 /system/priv-app/TouchInject/TouchInject.apk
# adb -s $SER reboot # permission grant is evaluated at install/boot
# adb -s $SER wait-for-device && adb -s $SER shell 'while [ -z $(getprop sys.boot_completed) ]; do sleep 1; done'
# adb -s $SER shell dumpsys package touch.inject | grep -A2 INJECT_EVENTS # granted=true
#
# --- Inject a gesture ------------------------------------------------------
# adb -s $SER shell am startservice -n touch.inject/.Injector \
# -e script "down 1 400 1000 wait 100 down 2 700 1200 wait 100 \
# move 1 380 980 move 2 720 1220 wait 50 \
# move 1 340 940 move 2 760 1260 wait 50 up 1 up 2"
# adb -s $SER logcat -d -s TouchInject

128
tools/touchinject/run_tests.sh Executable file
View File

@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Pinch e2e test harness for the Pad emulator (aosp_atd AVD).
#
# The platform-signed touch.inject app (real MotionEvents through
# InputManager — the only multi-touch path that works in the emulator)
# drives the renderer's pinch probe; each gesture below is ONE script in
# ONE process (the injector's finger map is per-process).
#
# KNOWN FLAKES (both verified, both handled below):
# 1. The injector process is "cached" while its script thread runs and the
# 1.5 GB emulator OOM-kills it mid-script occasionally — the script
# never reaches "=== done". ti() checks logcat for the done line and
# re-runs (a re-run starts a fresh gesture; a stuck pointer from a
# half-run is replaced by the next ACTION_DOWN).
# 2. The app is on-demand-rendering at ~1 fps: a burst of moves within
# one frame's drain does not scroll (single-frame delta pattern).
# Scroll tests therefore space the moves >= 40 ms apart — which is
# also what a real finger produces over several frames.
#
# READING RESULTS: AppFontScale in the session file is the reliable pinch
# signal. Scroll in the session is NOT (the restore re-derives it from the
# pinned line anchor); for scroll tests compare the before/after
# screenshots instead.
SER=emulator-5554
export ANDROID_SERIAL=$SER
ti() {
local script="$1"
for attempt in 1 2 3; do
adb logcat -c 2>/dev/null
adb shell 'am broadcast -n "touch.inject/.Injector$ScriptReceiver" --es script "'"$script"'"' >/dev/null 2>&1
sleep 4
if adb shell logcat -d 2>/dev/null | grep -aq "TouchInject: === done"; then
return 0
fi
echo " (injection incomplete, attempt $attempt — retrying)"
done
echo " !! injection failed after 3 attempts"
return 1
}
# ensure Pad is in the foreground (the ATD launcher intermittently holds
# focus after a cold start; a backgrounded app receives no touch)
fg() {
for i in 1 2 3 4 5; do
FOC=$(adb shell 'dumpsys window | grep mCurrentFocus')
case "$FOC" in *pad.pad*) return 0;; esac
adb shell am start -n pad.pad/org.gioui.GioActivity >/dev/null 2>&1
sleep 8
done
echo " !! Pad never gained focus: $FOC"
return 1
}
dbg() { adb shell "echo '$1' > /sdcard/PadPerf/cmd"; sleep 1.5; }
shot() { adb exec-out screencap -p > "$1"; }
# force-stop (flushes the session), print it, relaunch + reopen the file
measure() {
adb shell am force-stop pad.pad
sleep 4
echo "session: $(adb shell cat /sdcard/Pad/session.json 2>/dev/null)"
adb shell am start -n pad.pad/org.gioui.GioActivity >/dev/null 2>&1
sleep 4
}
case "$1" in
t1) # single-finger scroll must NEVER change the font (device regression)
echo "=== T1: single-finger scroll (font must not change) ==="
fg || exit 1
shot /tmp/t1_before.png
S="down 1 720 1400 wait 80"
for y in 1320 1240 1160 1080 1000 920 840 760 680 600 520 440 360 280 200; do
S="$S move 1 720 $y wait 40"
done
S="$S wait 100 up 1"
ti "$S"
sleep 2
shot /tmp/t1_after.png # compare to before: content moved, same font size
measure
;;
t2) # two-finger pinch-out: font must scale, center anchored
echo "=== T2: two-finger pinch-out (font must grow) ==="
fg || exit 1
shot /tmp/t2_before.png
S="down 1 570 1200 wait 40 down 2 870 1200 wait 60"
for i in 1 2 3 4 5 6 7 8; do
x1=$((570 - i*37)); x2=$((870 + i*37))
S="$S move 1 $x1 1200 move 2 $x2 1200 wait 50"
done
S="$S wait 150 up 1 up 2"
ti "$S"
sleep 1
shot /tmp/t2_after.png
measure
;;
t3) # palm-first: the resting finger lands FIRST, the pinch pair is the
# two that move — the palm must never enter the distance
echo "=== T3: palm-first three fingers (pinch pair = the two movers) ==="
fg || exit 1
shot /tmp/t3_before.png
S="down 1 720 400 wait 100 down 2 570 1200 wait 60 down 3 870 1200 wait 150"
for i in 1 2 3 4 5 6 7 8; do
x2=$((570 - i*37)); x3=$((870 + i*37))
S="$S move 2 $x2 1200 move 3 $x3 1200 wait 50"
done
S="$S wait 150 up 2 up 3 wait 50 up 1"
ti "$S"
sleep 1
shot /tmp/t3_after.png
measure
;;
t4) # lift one finger mid-pinch: survivor must SCROLL, not zoom
echo "=== T4: lift finger mid-pinch (survivor scrolls, no more zoom) ==="
fg || exit 1
shot /tmp/t4_before.png
S="down 1 570 1200 wait 40 down 2 870 1200 wait 60 move 1 533 1200 move 2 907 1200 wait 80 up 2 wait 80"
for y in 1280 1360 1440 1520 1600 1680 1760 1840 1920 2000; do
S="$S move 1 533 $y wait 40" # survivor (finger 1) keeps its own x
done
S="$S wait 100 up 1"
ti "$S"
sleep 1
shot /tmp/t4_after.png # compare to before: content scrolled, font frozen
measure
;;
reset) # back to a known state (one command per file — no pipes)
dbg "fontsize 1"; dbg "top"
;;
*) echo "usage: $0 t1|t2|t3|t4|reset" ;;
esac