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

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

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

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

277 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# Emulator + on-device debug helpers for Pad (machine-local workstation).
#
# Usage:
# scripts/emu.sh up # start headless AVD, wait for boot
# scripts/emu.sh down # stop the emulator (clean; saves snapshot)
# scripts/emu.sh status # adb / boot / top-activity state
# scripts/emu.sh app start|stop|restart
# scripts/emu.sh shot [FILE] # screenshot (default /tmp/pad_shot_<ts>.png)
# scripts/emu.sh log [LINES] # last N lines of app logcat (default 20)
# scripts/emu.sh tap X Y # tap at SCREEN px (1080x2400 space).
# # Tap-what-you-see rule: read (x,y) off
# # the PNG you are viewing and multiply
# # both by 1.2 (vision displays
# # screenshots at 900x2000). Note: the
# # 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|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/
# scripts/emu.sh pull NAME # pull Notes/NAME -> ./
#
# Machine-local facts encoded here (see doc/README.md for context):
# - AVD pad_avd (Pixel 6 profile, API 35, x86_64); headless + swiftshader.
# Boot is fast (snapshot restore ~10 s, cold boot ~20-30 s warm).
# - App pad.pad / org.gioui.GioActivity.
# - Profiler: enable marker + CSV live under /storage/emulated/0/PadPerf/;
# 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"; 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.
#
# Snapshots: the AVD auto-saves a "default_boot" instant-boot snapshot on
# clean shutdown. A corrupted snapshot makes the emulator segfault during
# restore (device never comes online). `up` detects that (process death or
# no adb device within PAD_ONLINE_TIMEOUT seconds) and falls back to a cold
# boot with -no-snapshot-load; the next clean `down` re-saves a healthy
# snapshot, so this self-heals. Always stop with `down` (clean kill saves
# the snapshot) — SIGKILLing the emulator corrupts the snapshot.
#
# Env overrides: PAD_AVD, PAD_ONLINE_TIMEOUT (default 60 s),
# PAD_BOOT_TIMEOUT (default 300 s), EMU_LOG path via ANDROID_HOME-adjacent.
set -euo pipefail
AVD=${PAD_AVD:-pad_avd}
PKG=pad.pad
ACTIVITY=org.gioui.GioActivity
NOTES=/storage/emulated/0/Notes
PERFDIR=/storage/emulated/0/PadPerf
EMU_LOG=${PAD_EMU_LOG:-/tmp/emulator.log}
ONLINE_TIMEOUT=${PAD_ONLINE_TIMEOUT:-60}
BOOT_TIMEOUT=${PAD_BOOT_TIMEOUT:-300}
export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk}
export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator
die() { echo "ERROR: $*" >&2; exit 1; }
adb_state() { adb get-state 2>/dev/null || echo offline; }
booted() {
[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]
}
EMU_PID=""
# Find the actual qemu process for this AVD: comm is qemu-system-* and the
# command line contains " -avd $AVD". The comm check excludes the emulator's
# crashpad/netsimd children (their command lines carry the AVD name too) and
# any unrelated process that merely mentions the AVD. Prints the pid.
# Returns 0 if found, 1 otherwise.
find_emu_qemu() {
local p
for p in $(pgrep -f " -avd $AVD" 2>/dev/null); do
case "$(ps -o comm= -p "$p" 2>/dev/null)" in
qemu-system-*) echo "$p"; return 0 ;;
esac
done
return 1
}
start_emulator() {
nohup emulator -avd "$AVD" -no-window -no-audio -no-boot-anim \
-gpu swiftshader_indirect "$@" >"$EMU_LOG" 2>&1 &
EMU_PID=$!
}
# Wait until the adb device is online.
# Returns: 0 online, 1 emulator process died, 2 timeout.
wait_device_online() {
local deadline=${1:-$ONLINE_TIMEOUT} i=0
while [ "$i" -lt "$deadline" ]; do
[ "$(adb_state)" = "device" ] && return 0
# Give the launcher time to exec into qemu before declaring death.
if [ "$i" -ge 10 ] && ! find_emu_qemu >/dev/null; then
return 1
fi
sleep 3
i=$((i + 3))
done
return 2
}
# Wait for boot_completed on an already-online device.
# Returns: 0 booted, 1 not booted (device lost or timeout).
wait_boot_completed() {
local deadline=${1:-$BOOT_TIMEOUT} i=0
while [ "$i" -lt "$deadline" ]; do
booted && return 0
[ "$(adb_state)" = "device" ] || return 1
sleep 5
i=$((i + 5))
done
return 1
}
cmd_up() {
if [ "$(adb_state)" = "device" ] && booted; then
echo "emulator already up and booted"
return 0
fi
if [ "$(adb_state)" = "device" ]; then
echo "emulator present; waiting for boot..."
wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG"
echo "booted"
return 0
fi
echo "starting AVD $AVD (headless)..."
# Clear any orphaned qemu left by a previously force-killed run: it would
# hold the AVD lock and make the new launch fail silently.
local qp
if qp=$(find_emu_qemu); then
echo "WARN: orphaned emulator (pid $qp) still running; killing it first"
kill -9 "$qp" 2>/dev/null || true
sleep 2
fi
start_emulator "$@"
local rc=0
wait_device_online "$ONLINE_TIMEOUT" || rc=$?
if [ "$rc" -ne 0 ]; then
if [ "$rc" -eq 1 ]; then
echo "WARN: emulator process died during startup (corrupt snapshot?) — $EMU_LOG"
else
echo "WARN: device never came online within ${ONLINE_TIMEOUT}s — $EMU_LOG"
fi
echo "retrying with -no-snapshot-load (cold boot; next 'down' re-saves a good snapshot)..."
# Kill the old instance (launcher and/or qemu). SIGKILL: the snapshot
# is already broken, there is nothing to save, and a half-written
# snapshot is harmless (next clean 'down' re-saves).
qp=$(find_emu_qemu || true)
if [ -n "$qp" ]; then kill -9 "$qp" 2>/dev/null || true; fi
kill -9 "$EMU_PID" 2>/dev/null || true
# Wait until it is truly gone: the old device and AVD lock must be
# released before the replacement starts, or the new waits can latch
# onto the dying instance and report false success.
local j=0
while [ "$j" -lt 15 ]; do
find_emu_qemu >/dev/null 2>&1 || break
sleep 1
j=$((j + 1))
done
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"; }
fi
wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG"
echo "booted"
}
cmd_down() {
adb emu kill 2>/dev/null || die "no emulator running"
echo "stopped"
}
cmd_status() {
echo "adb: $(adb_state)"
if [ "$(adb_state)" = "device" ]; then
echo "boot_completed: $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
local top
top=$(adb shell dumpsys activity activities 2>/dev/null | grep -m1 "topResumedActivity" || true)
echo "top: ${top:-none}"
fi
}
cmd_app() {
case "${1:-}" in
start) adb shell am start -n "$PKG/$ACTIVITY" >/dev/null; echo "started" ;;
stop) adb shell am force-stop "$PKG"; echo "stopped" ;;
restart)
adb shell am force-stop "$PKG"
sleep 1
adb shell am start -n "$PKG/$ACTIVITY" >/dev/null
echo "restarted"
;;
*) die "app: start|stop|restart" ;;
esac
}
cmd_shot() {
local out="${1:-/tmp/pad_shot_$(date +%Y%m%d_%H%M%S).png}"
adb exec-out screencap -p >"$out"
echo "$out"
}
cmd_log() {
adb logcat -d -s "$PKG" | tail -n "${1:-20}"
}
cmd_tap() {
[ $# -eq 2 ] || die "tap X Y"
adb shell input tap "$1" "$2"
}
cmd_type() {
[ $# -ge 1 ] || die "type TEXT"
# input text takes one arg; encode spaces as %s.
local text=${*// /%s}
adb shell input text "$text"
}
cmd_cmd() {
# Commands are constrained (top|bottom|frac F|dp N) — no shell metachars.
[ $# -ge 1 ] || die "cmd <top|bottom|frac F|dp N>"
adb shell "mkdir -p $PERFDIR && printf '%s\n' \"$*\" > $PERFDIR/cmd"
echo "sent editor debug command: $*"
}
cmd_perf() {
case "${1:-}" in
on) adb shell "mkdir -p $PERFDIR && touch $PERFDIR/enable"; echo "profiler on" ;;
off) adb shell "rm -f $PERFDIR/enable"; echo "profiler off" ;;
pull)
local out="${2:-./logic_frames.csv}"
adb pull "$PERFDIR/logic_frames.csv" "$out"
;;
*) die "perf on|off|pull [FILE]" ;;
esac
}
cmd_push() {
[ $# -ge 1 ] || die "push FILE"
adb push "$1" "$NOTES/"
}
cmd_pull() {
[ $# -ge 1 ] || die "pull NAME"
adb pull "$NOTES/$1" "./"
}
[ $# -ge 1 ] || die "no subcommand (see header)"
main="${1}"; shift
case "$main" in
up) cmd_up "$@" ;;
down) cmd_down "$@" ;;
status) cmd_status "$@" ;;
app) cmd_app "$@" ;;
shot) cmd_shot "$@" ;;
log) cmd_log "$@" ;;
tap) cmd_tap "$@" ;;
type) cmd_type "$@" ;;
cmd) cmd_cmd "$@" ;;
perf) cmd_perf "$@" ;;
push) cmd_push "$@" ;;
pull) cmd_pull "$@" ;;
*) die "unknown subcommand: $main" ;;
esac