From 94faa23122deb68036f4eec600ebe1deadeaefac Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:54:13 +0800 Subject: [PATCH] docs(touch): the touch system guide + measured cost model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The developer path through the touch pyramid — components (Focusable / TextField / VirtualList) over gesture algebra (createGesture / createScroller) over raw contacts — with the claim model, hit facts (why touchRect does not exist), modality degradation, the testing story, and the MEASURED cost table (counting-host over the real frame pump: idle 0 ops/frame, steady fling 1, window-churn bursts <=15 — 0.2% of the PSP op budget). Co-Authored-By: Claude Fable 5 --- docs/TOUCH.md | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/TOUCH.md diff --git a/docs/TOUCH.md b/docs/TOUCH.md new file mode 100644 index 00000000..287d96b8 --- /dev/null +++ b/docs/TOUCH.md @@ -0,0 +1,228 @@ +# PocketJS Touch — 从零到手感 + +在 PocketJS 里,触摸不是事件系统,是输入模态之一。你不写"点击处理器", +你声明"这个东西可以被激活/被滚动/被拖拽"——手指、d-pad、光标殊途同归。 +写对了,你的应用在 Vita 触屏、PSP 十字键、桌面光标上跑同一份代码,零分支。 + +整个体系是一座三层金字塔,**从上往下用,能停就停**: + +``` +第 3 层 组件:Focusable / TextField / VirtualList ← 90% 的应用停在这层 +第 2 层 手势代数:createGesture / createScroller ← 自定义组件的作者才下来 +第 1 层 原始触点:touches() ← 你几乎永远不需要 +``` + +每往下一层,你对确定性和多模态降级承担更多责任;停在上层,这两件事是平台的。 + +## 0. 一个按钮(你可能根本不需要 Touch API) + +```tsx +import { Focusable, Text } from "@pocketjs/framework/components"; + + play(video)} + class="rounded-lg bg-[#1a2432] active:bg-[#243040]"> + PLAY + +``` + +tap、CIRCLE、光标点击走同一个 `onPress`;`active:` 变体在三种模态下行为 +一致——手指按下高亮、松开触发、滑走取消(列表开始滚动时高亮自动熄灭, +见 §3 的认领)。注意你没有 import 任何 touch 相关的东西:激活是焦点体系 +的语义,触摸只是到达它的一条路。 + +## 1. 文本输入:TextField + +```tsx +import { TextField } from "@pocketjs/framework/osk"; + + +``` + +`TextField` = focusable + **editable**。任何模态的激活——点它、CIRCLE、 +光标点击——自动召唤系统 OSK 并绑定这个字段;面板经 overlay 停靠,打开 +期间身后的按键与手势被模态屏蔽,提交触发 `onSubmit` 并关闭。你不需要 +管理 `isOpen`/`display` 镜像,不需要绑按键(想要 △ 快捷键:`ref` 暴露 +控制器,`ref.open()` 一行)。字段与键盘是同一个垂直切片,所以都住在 +osk 模块。 + +## 2. 长列表:VirtualList + +```tsx +import { VirtualList, type VirtualListHandle } from "@pocketjs/framework/virtual-list"; + +let list: VirtualListHandle; + + } + onRowPress={(i) => play(results()[i])} + onNearEnd={() => loadMore()} + ref={(h) => (list = h)} +/> +``` + +免费得到:触摸 pan 跟手 + iOS 手感 fling(衰减/rubber-band/带初速回弹 +都是平台常量)、行按压高亮与滚动取消、d-pad 焦点行走 + chase、光标 +hover=focus、O(visible) 窗口化。Handle 补命令式入口: +`scrollToIndex(i, "center")`、`focusRow(0)`、`rebaseRows(n)`(顶部 +prepend 不跳屏);聊天流加 `stickToBottom`。 + +行归属来自命中事实(§5):手指落在哪一行由**节点身份**决定,不需要告诉 +组件任何屏幕几何;行间隙的 tap 是分隔条 tap——不触发(UIKit 惯例)。 + +## 3. 自定义手势:createGesture + +什么时候下到这层:你要做的交互既不是激活也不是列表——播放器的划动、 +进度条的 scrub、图片的拖拽。 + +```tsx +import { createGesture } from "@pocketjs/framework/gesture"; + +// 播放器:tap 切 HUD,下滑快甩退出 +createGesture({ + onTap: () => toggleHud(), + onPanEnd: (c) => { if (c.dy > 80 && c.vy > 240) exitPlayer(); }, +}); + +// 进度条:x 轴锁定,拖动预览,松手才 seek +createGesture({ + region: { node: () => scrubNode }, + axis: "x", + onPanMove: (c) => setPreview(fracFor(c.x)), + onPanEnd: (c) => seekTo(fracFor(c.x)), +}); +``` + +`c` 是 `GestureContact`: + +| 字段 | 含义 | +|---|---| +| `x, y` / `startX, startY` | 当前/落点位置(逻辑 px) | +| `dx, dy` / `fdx, fdy` | 相对落点/本帧的位移 | +| `vx, vy` | 速度,逻辑 px/虚拟秒 | +| `hit` | 落点的命中事实(节点 id;§5) | +| `id, frames, downFrame` | 触点标识 / 按住帧数 / 落下帧 | + +**唯一需要内化的概念:认领(claim)。** 多个识别器可以同时观察同一根 +手指;第一个越过 `panSlop` 的识别器认领它,其余全部收到 `onCancel`—— +"列表开始滚动,行的按压高亮熄灭"就是这个机制,你不写一行协调代码。 +优先级 = 注册顺序,后注册者优先(内层组件后挂载,天然赢外层)。 +`region.node` 圈定命中子树;不给 region 就是全屏识别器。速度和长按走 +虚拟时钟——30Hz 模拟测试与 60Hz 真机数值逐位一致。 + +## 4. 自定义动力学表面:createScroller + +VirtualList 不合身时(画布平移、carousel、缩放视口),物理引擎单独可用: + +```tsx +import { createScroller, bindDpadScroll } from "@pocketjs/framework/kinetics"; +import { createGesture } from "@pocketjs/framework/gesture"; + +const s = createScroller({ max: () => contentH() - viewH() }); + +createGesture({ + region: { node: () => viewportNode }, + axis: "y", + onDown: () => s.stop(), // 手指落下,截停飞行中的 fling + onPanStart: () => s.beginDrag(), + onPanMove: (c) => s.drag(c.dy), + onPanEnd: (c) => s.endDrag(-c.vy), // 松手速度直接变 fling 初速 +}); +bindDpadScroll(s, { active: () => focused() }); // PSP 降级,一行 + +// 渲染侧:每帧一次 paint-only 属性,零重排 + +``` + +`snap` 做分页/对齐(接收投影静止点,返回吸附目标),`onSettle` 报静止。 +物理常量(fling 衰减、rubber 曲线、spring K/C)是平台字面量——所有 +应用同一手感,所有宿主同一数值。物理积分刻意留在 guest 侧:窗口化每帧 +要同步读 offset,而热路径读取永不跨界(定律一)。 + +## 5. 命中事实(为什么没有 touchRect 这种东西) + +"手指落在哪"是 core 的知识(它拥有 layout 与绘制),所以它是宿主投递的 +**事实**,不是 guest 发起的查询:宿主在触点**落下**的那一帧,对用户正在 +看的已提交画面做一次**按布局盒**的命中(spec op 42 的语义:纯布局容器 +认领自己的盒;行间隙也归列表),然后**随触点生命周期携带**(隐式捕获), +经 `frame()` 第 4 参与触点并行送达。`GestureContact.hit` 就是它。 + +- 拖动期间零 FFI:命中永不重解析 +- 空的 overlay/portal 层标记 `hitPass`(自身对命中透明,内容照常认领) + ——engine 版 pointer-events:none,系统 OSK 的停靠依赖它 +- 无事实通道时(devtools 回放、注入宿主、旧 wasm)手势层按 + op 42 → op 27 → region.rect 逐级回退,确定性不变 + +## 6. 降级不是你的工作(但有一个开关) + +| | Vita(触摸) | PSP(d-pad) | 桌面(光标) | +|---|---|---|---| +| `Focusable.onPress` | tap | CIRCLE | click | +| `TextField` | tap → OSK | CIRCLE → OSK | click → OSK | +| `VirtualList` | fling/tap | 焦点行走/chase | hover/click | +| `createScroller` | 手势驱动 | `bindDpadScroll` | 光标拖拽 | + +唯一值得写分支的场景:交互**习语**本身不同。判据是能力,不是机型: + +```tsx +import { hasFeature } from "@pocketjs/framework/platform"; + +// 触屏习语是无限滚动;按键习语是明确的 LOAD MORE 行 +onNearEnd={hasFeature("input.touch") ? loadMore : undefined} +``` + +## 7. 模态表面 + +OSK 自动屏蔽身后的按键与手势。自建 modal 同款一行: + +```tsx +import { pushTouchBlock } from "@pocketjs/framework/gesture"; +onCleanup(pushTouchBlock()); // 挂上即屏蔽,清理即恢复;在途触点当帧 onCancel +``` + +## 8. 测试你的手感 + +手势输出是 `(tick, inputs)` 的纯函数,手感像逻辑一样可断言: + +- **sim journey**:`touchGlide(x0,y0,x1,y1,t0,t1)` 脚本化一记 fling, + 断言帧哈希逐字节复跑(tests/im-sim.test.ts Journey E 是范本) +- **录/放**:`bun tools/tape.ts record --touch` 录 tape v2 稀疏触点轨道; + 回放逐字节。命中事实**不录**——回放经查询回退确定性重解析 +- **golden**:`GoldenSpec.touch(frame)` 让 e2e 在真渲染核上钉住 + "按住第 N 帧的按压高亮"这类按键 tape 表达不了的状态 + +## 9. 成本模型(实测) + +计数宿主 + 真帧泵,200×68px 行的 VirtualList,快拖 8 帧 + fling 到静止: + +| 阶段 | ops/帧 | 说明 | +|---|---|---| +| 静止(前后各 30 帧) | **0** | 泵是两次比较,零分配 | +| 手指落下 | 1 (+1 次核内命中) | `setActive` 行高亮;命中每触点一生一次 | +| 拖拽 | 均值 7.6,峰值 15 | 纯跟手帧 = **1**(translateY);换窗帧 = 15 | +| fling(185 帧) | 均值 2.1,峰值 15 | 稳态帧 = 1 个 setProp;真宿主还会把 prop 三元组批成单次穿越 | + +参照:PSP 实测 FFI 预算 ~8000 ops/帧——峰值占 **0.2%**。手势与动力学 +本身零南向穿越:识别、认领、速度估计、物理积分全部发生在 guest 内存; +过边界的只有像素变更(任何动画都付)与每触点一次的核内命中。 + +结构性保证(引擎侧的复杂度承诺): + +``` +手势泵 O(contacts + recognizers)/tick,稳态零分配(8 槽定长池) +scroller O(1)/tick,纯字面量常量,IEEE 位一致 +VirtualList O(visible),窗口 memo 引用稳定 +命中解析 O(新 contact × 树深),仅落下帧;无触摸 = 零 +wire ≤8×u32 电平快照 + 并行事实数组 +``` + +## 10. 该记住的五件事 + +1. 先不用 Touch API:`Focusable onPress` + `TextField` + `VirtualList` +2. 自定义交互才用 `createGesture`,理解**认领**一个概念 +3. 自定义滚动物理用 `createScroller`,绑 `translateY: -offset()` +4. 分支只写在 `hasFeature("input.touch")`,且仅当习语不同 +5. 手感用 journey/tape 断言,和逻辑一样