gallery/harnesses/tttest/frontend/dist/index.html
Greg Pomerantz d82ecf74ac harness: tttest — Tauri 2 / WebView scroll-jank testbed (third stack)
The A/B/C harness series: sltest (Slint) / egtest (egui) / tttest
(Tauri 2 → wry → Android WebView/Chromium). Identical scene to
sltest: 8580 full-width rows, 150 px + 2 px divider, alternating
colors, virtualized (26-element DOM pool), custom touch drag +
exponential-decay fling (sltest decay constant 7.42), rAF-driven
frame loop.

FR logging: the rAF callback records gap/cost exactly like the Rust
harnesses; lines batch (~120) and cross the Tauri event bridge once
per second (JNI round trip kept off the per-frame path). Rust side
drains to a dedicated writer thread →
/sdcard/Android/data/wow.gmp.tttest/files/Documents/fr.log
(Tauri document_dir = getExternalFilesDir(DOCUMENTS) — the framework
CREATES it; a raw std::fs mkdir of the app's external tree is
SELinux-denied on Android 11+). Same FR-line format → fr_capture.py
is the referee (its launch check now falls back to the package's
LAUNCHER activity via monkey for non-NativeActivity apps).

Gotchas learned (see frontend/dist/index.html comments):
- touch-action is NOT inherited: touches land on .row, so each row
  must declare touch-action:none or the WebView claims the pan and
  cancels the page's drag (only a few px register).
- Fling velocity must be a per-EVENT EMA; frame-based tracking dies
  when touch events sample slower than rAF (adb swipe ≈ 17 Hz).
- Drag sign: finger up → offset grows (offset -= finger dy).
- Tauri v2 'brownfield' is the NORMAL direct-IPC mode (not an
  app-provides-content mode); the mobile schema rejects
  security.pattern entirely.
- Custom #[tauri::command]s hit the two-pass app-command ACL
  chicken-and-egg; the core:default event bridge avoids it.
- Rust log output needs a logcat backend (log/std +
  __android_log_write) or it is invisible.

Emulator (60 Hz, scripted swipes): 1319 frames, median 16.70 ms,
5 isolated drops (0.38%), all at swipe-injection moments, render
cost med 2 ms. aarch64 phone build compiles (11 MB APK); phone
testing pending (personal-device policy).

Build: harnesses/tttest/scripts/build.sh [x86_64|aarch64] —
cargo-tauri CLI (v2.12.4, prebuilt) drives the gradle build; signed
with the debug keystore.
2026-10-31 13:53:43 -04:00

216 lines
6.5 KiB
HTML

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>tttest</title>
<style>
/* Full-screen, no native scroll of ANY kind: all motion is ours, on
the compositor layer (transform: translate3d). Mirrors sltest:
full-width rows, 150 px + 2 px white divider, alternating colors. */
html, body {
margin: 0; padding: 0;
position: fixed; inset: 0;
width: 100%; height: 100%;
overflow: hidden;
background: #0a0a0c;
touch-action: none;
overscroll-behavior: none;
-webkit-user-select: none; user-select: none;
}
#rows { position: absolute; left: 0; top: 0; width: 100%; height: 0; will-change: transform; }
.row {
position: absolute; left: 0; width: 100%; height: 150px;
will-change: transform;
/* touch-action is NOT inherited — touches land on .row, so each must
claim the gesture. Otherwise the WebView sees touch-action:auto at
the hit element and takes over the pan (page gets touchcancel),
leaving only a few px of drag registered. */
touch-action: none;
}
.row .div {
position: absolute; left: 0; bottom: 0; width: 100%; height: 2px;
background: #ffffff;
}
</style>
</head>
<body>
<div id="rows"></div>
<script>
"use strict";
let frames = 0;
// ---- scene (sltest mirror) -------------------------------------------
const N = 8580; // rows (same as the big sltest scene)
const ROW = 150, DIV = 2, RH = ROW + DIV;
const W = () => window.innerWidth;
const H = () => window.innerHeight;
const MAXOFF = () => Math.max(0, N * RH - H());
const COLOR = [
[60, 90, 130], // blue
[130, 70, 90], // maroon
];
// ---- virtualization: a fixed pool of rows, repositioned per frame ----
const POOL = 26;
const wrap = document.getElementById("rows");
const pool = [];
for (let i = 0; i < POOL; i++) {
const d = document.createElement("div");
d.className = "row";
const v = document.createElement("div");
v.className = "div";
d.appendChild(v);
wrap.appendChild(d);
pool.push({ el: d, color: -1, y: -1 });
}
// ---- scroll state ------------------------------------------------------
let offset = 0; // content px scrolled (0 = top)
let dragging = false;
let dragY0 = 0, dragOff0 = 0;
let vel = 0; // content px/s (positive = scrolling down)
let lastT = 0, lastY = 0;
function clampOffset(o) {
if (o < 0) return 0;
const m = MAXOFF();
return o > m ? m : o;
}
function applyOffset() {
wrap.style.transform = "translate3d(0," + (-offset).toFixed(2) + "px,0)";
}
function paint() {
const h = H();
const first = Math.max(0, Math.floor(offset / RH) - 2);
const last = Math.min(N - 1, Math.ceil((offset + h) / RH) + 2);
for (let i = 0; i < POOL; i++) {
const p = pool[i];
const r = first + i;
if (r > last) { p.el.style.display = "none"; continue; }
p.el.style.display = "block";
if (r % 2 !== p.color) {
const c = COLOR[r % 2];
p.el.style.background = "rgb(" + c[0] + "," + c[1] + "," + c[2] + ")";
p.color = r % 2;
}
const y = Math.round(r * RH * 100) / 100;
if (y !== p.y) {
p.el.style.transform = "translate3d(0," + y + "px,0)";
p.y = y;
}
}
}
// ---- FR logging (fr_capture.py convention) -----------------------------
// The rAF timestamp is the Chromium render thread's high-res clock.
// gap = time since the PREVIOUS frame callback (the frame period the
// user sees); cost = this callback's own work. Batches ~1/s keep the
// Tauri IPC (a JNI round trip) at 1/s, off the per-frame path.
let frLast = -1;
let frBatch = [];
let frLines = 0;
function frLog(t) {
const work = performance.now() - t;
if (frLast >= 0) {
const gap = t - frLast;
frBatch.push("FR " + t.toFixed(2).padStart(11) +
" gap=" + gap.toFixed(2).padStart(7) +
" cost=" + work.toFixed(2).padStart(6) + " drew=1");
if (++frLines % 120 === 0) {
flushFr();
}
}
frLast = t;
}
function flushFr() {
if (frBatch.length === 0) return;
const payload = frBatch.join("\n") + "\n";
frBatch = [];
try {
window.__TAURI__.event.emit("tttest-fr", payload);
} catch (e) { /* pre-bridge (desktop) */ }
}
// ---- frame loop (runs ALWAYS; Chromium drives the rate) ----------------
function frame(t) {
frames++;
const t0 = performance.now();
if (dragging) {
// 1:1 finger follow (velocity is tracked per-EVENT in touchmove —
// frame-based EMA dies when touch events sample slower than rAF).
offset = clampOffset(dragOff0 - (yNow - dragY0));
lastT = t;
} else if (Math.abs(vel) > 0) {
const dt = Math.min(0.1, (t - lastT) / 1000);
offset = clampOffset(offset + vel * dt);
vel = vel * Math.exp(-7.42 * dt); // sltest/gallery decay (0.94 @120fps)
if (Math.abs(vel) < 1100) vel = 0;
lastT = t;
}
paint();
applyOffset();
frLog(t);
requestAnimationFrame(frame);
}
// ---- touch (pointer events; single finger) ------------------------------
let yNow = 0;
// Per-event velocity (the gallery's fixed estimator): delta between
// successive touch events ÷ time between them, EMA 0.8/0.2 (~60 ms
// memory at 120 Hz). Frame-based tracking breaks when events sample
// slower than rAF (adb swipes arrive ~17 Hz).
let evY = 0, evT = 0;
window.addEventListener("touchstart", (e) => {
const t = e.changedTouches[0];
yNow = t.clientY;
dragging = true;
dragY0 = yNow;
dragOff0 = offset;
vel = 0;
evY = yNow;
evT = performance.now();
lastT = performance.now();
e.preventDefault();
}, { passive: false });
window.addEventListener("touchmove", (e) => {
const t = e.changedTouches[0];
yNow = t.clientY;
const now = performance.now();
const dtv = (now - evT) / 1000;
if (dtv > 0.002) {
const inst = (evY - yNow) / dtv; // finger up → positive (offset grows)
vel = 0.8 * vel + 0.2 * inst;
evY = yNow;
evT = now;
}
e.preventDefault();
}, { passive: false });
window.addEventListener("touchend", (e) => {
dragging = false;
lastT = performance.now();
e.preventDefault();
}, { passive: false });
window.addEventListener("touchcancel", () => { dragging = false; vel = 0; }, { passive: false });
// ---- lifecycle ------------------------------------------------------------
window.addEventListener("load", () => {
paint();
applyOffset();
requestAnimationFrame(frame);
try {
window.__TAURI__.event.emit("tttest-log", {
msg: "webview up " + window.innerWidth + "x" + window.innerHeight +
" dpr=" + window.devicePixelRatio,
});
} catch (e) { /* desktop */ }
});
</script>
</body>
</html>