@quiesco/agent 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +23 -2
- package/metro.cjs +144 -0
- package/package.json +54 -3
- package/src/actions/hit-test.ts +42 -0
- package/src/actions/host.ts +194 -0
- package/src/actions/js.ts +245 -0
- package/src/actions/multitouch.ts +179 -0
- package/src/actions/native.ts +500 -0
- package/src/actions/performer.ts +87 -0
- package/src/actions/value.ts +460 -0
- package/src/actions/wheel.ts +260 -0
- package/src/api-types.ts +32 -0
- package/src/api.ts +11 -0
- package/src/assertions/index.ts +160 -0
- package/src/backdoor.ts +54 -0
- package/src/build-config.ts +6 -0
- package/src/connection/client.ts +196 -0
- package/src/connection/params.ts +63 -0
- package/src/early.ts +10 -0
- package/src/errors.ts +23 -0
- package/src/events.ts +136 -0
- package/src/expo-fetch.js +13 -0
- package/src/fiber-adapter/default-deps.native.ts +36 -0
- package/src/fiber-adapter/default-deps.ts +38 -0
- package/src/fiber-adapter/fiber.ts +81 -0
- package/src/fiber-adapter/hook.ts +154 -0
- package/src/fiber-adapter/host.ts +210 -0
- package/src/fiber-adapter/index.ts +221 -0
- package/src/fiber-adapter/scheduler.ts +70 -0
- package/src/fiber-adapter/traverse.ts +260 -0
- package/src/fiber-adapter/types.ts +142 -0
- package/src/index.ts +7 -0
- package/src/install.ts +6 -0
- package/src/json-check.ts +43 -0
- package/src/platform.ts +155 -0
- package/src/query/index.ts +5 -0
- package/src/query/matcher.ts +121 -0
- package/src/query/semantic.ts +266 -0
- package/src/query/tree.ts +116 -0
- package/src/query/visibility.ts +60 -0
- package/src/reset/index.ts +59 -0
- package/src/reset/storage-defaults.native.ts +26 -0
- package/src/reset/storage-defaults.ts +39 -0
- package/src/root/QuiescoRoot.tsx +28 -0
- package/src/runtime.ts +1003 -0
- package/src/singleton.ts +18 -0
- package/src/stub.ts +28 -0
- package/src/sync/frame-stable.ts +142 -0
- package/src/sync/idle-callback.ts +142 -0
- package/src/sync/index.ts +17 -0
- package/src/sync/native-settle.ts +41 -0
- package/src/sync/network.ts +299 -0
- package/src/sync/offline.ts +121 -0
- package/src/sync/react.ts +28 -0
- package/src/sync/timer.ts +124 -0
- package/src/sync/tracker.ts +51 -0
- package/src/sync/wait-for-idle.ts +141 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `js` action mode (requirements.md §6.3): call the components' callbacks directly. Fast and needs no
|
|
3
|
+
* native code, but skips hit testing. Limitations (documented):
|
|
4
|
+
* - tap / longPress / doubleTap call the nearest `onPress` / `onLongPress` found from the element up
|
|
5
|
+
* to the root, together with that component's `onPressIn` / `onPressOut`. A component with
|
|
6
|
+
* `disabled: true` is not pressed (like Pressability). Gesture-handler gestures (`Gesture.Tap()` ...)
|
|
7
|
+
* have no such prop and are not supported.
|
|
8
|
+
* - type / clear call `onChangeText` (and `onChange`) of the nearest TextInput with the new full text.
|
|
9
|
+
* Controlled inputs update through their `value`; uncontrolled inputs keep their native text.
|
|
10
|
+
* `type` appends to the current `value` (or `defaultValue`) in a single call, not key by key.
|
|
11
|
+
* - scroll / swipe / scrollTo call `scrollTo({ x, y, animated: false })` on the nearest ScrollView
|
|
12
|
+
* (FlatList / SectionList included). Other scrollables (pager views ...) are not supported. `from`
|
|
13
|
+
* (a start point) has no meaning here and is ignored.
|
|
14
|
+
* - A tap at a point (`tap(t, { point })`, `tapAt`) presses the topmost element under that point.
|
|
15
|
+
* - A tap on a switch / checkbox without onPress calls `onValueChange(!value)` (unless disabled).
|
|
16
|
+
* - pressKey: `enter` calls `onSubmitEditing` (a multi-line input without it gets a newline);
|
|
17
|
+
* `backspace` calls `onKeyPress` and removes the last character.
|
|
18
|
+
* - drag is not supported (it needs real touches).
|
|
19
|
+
*/
|
|
20
|
+
import type { Direction, Edge, KeyName, Point, Rect } from '@quiesco/protocol';
|
|
21
|
+
|
|
22
|
+
import { QuiescoError } from '../errors';
|
|
23
|
+
import type { HandlerRef, HostNode } from '../fiber-adapter';
|
|
24
|
+
import { hitTest } from './hit-test';
|
|
25
|
+
|
|
26
|
+
export interface JsActionContext {
|
|
27
|
+
platform: string;
|
|
28
|
+
/** Current host roots, for taps at a point (hit testing). */
|
|
29
|
+
roots?: () => readonly HostNode[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Holding at least this long is a long press (`longPressAt`). */
|
|
33
|
+
export const LONG_PRESS_THRESHOLD_MS = 500;
|
|
34
|
+
|
|
35
|
+
/** Synthetic event passed to press handlers: the fields user code commonly reads. `at`: window point. */
|
|
36
|
+
function pressEvent(node: HostNode, type: string, at?: Point): Record<string, unknown> {
|
|
37
|
+
const r = node.measure();
|
|
38
|
+
const cx = at?.x ?? (r === null ? 0 : r.x + r.width / 2);
|
|
39
|
+
const cy = at?.y ?? (r === null ? 0 : r.y + r.height / 2);
|
|
40
|
+
const nativeEvent = {
|
|
41
|
+
locationX: r === null ? 0 : cx - r.x,
|
|
42
|
+
locationY: r === null ? 0 : cy - r.y,
|
|
43
|
+
pageX: cx,
|
|
44
|
+
pageY: cy,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
identifier: 0,
|
|
47
|
+
touches: [],
|
|
48
|
+
changedTouches: [],
|
|
49
|
+
};
|
|
50
|
+
let defaultPrevented = false;
|
|
51
|
+
return {
|
|
52
|
+
type,
|
|
53
|
+
nativeEvent,
|
|
54
|
+
timeStamp: nativeEvent.timestamp,
|
|
55
|
+
persist() {},
|
|
56
|
+
preventDefault() {
|
|
57
|
+
defaultPrevented = true;
|
|
58
|
+
},
|
|
59
|
+
isDefaultPrevented: () => defaultPrevented,
|
|
60
|
+
stopPropagation() {},
|
|
61
|
+
isPropagationStopped: () => false,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type JsAction = 'tap' | 'longPress' | 'doubleTap' | 'multiTap' | 'type' | 'replaceText' | 'clear' | 'pressKey' | 'scroll' | 'swipe' | 'scrollTo' | 'drag' | 'tapAt' | 'pinch' | 'rotate' | 'multiTouch';
|
|
66
|
+
|
|
67
|
+
export function notSupported(action: JsAction, ctx: JsActionContext, hint: string): QuiescoError {
|
|
68
|
+
return new QuiescoError('ACTION_NOT_SUPPORTED', `${action} is not possible in js mode: ${hint}`, { action, platform: ctx.platform, hint });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isDisabled(node: HostNode, owner: HandlerRef): boolean {
|
|
72
|
+
if (owner.props['disabled'] === true) return true;
|
|
73
|
+
const a11y = node.props['accessibilityState'] as { disabled?: unknown } | undefined;
|
|
74
|
+
return a11y?.disabled === true || node.props['aria-disabled'] === true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function call(owner: HandlerRef, name: string, event: unknown): void {
|
|
78
|
+
const fn = owner.props[name];
|
|
79
|
+
if (typeof fn === 'function') (fn as (e: unknown) => unknown)(event);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A tap on a switch / checkbox without onPress toggles it through `onValueChange` (what the native control does). */
|
|
83
|
+
function toggle(node: HostNode): boolean {
|
|
84
|
+
const owner = node.findHandler('onValueChange');
|
|
85
|
+
if (owner === null || typeof owner.props['value'] !== 'boolean') return false;
|
|
86
|
+
if (owner.props['disabled'] === true) return true;
|
|
87
|
+
owner.fn(!owner.props['value']);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function press(node: HostNode, handlerName: 'onPress' | 'onLongPress', action: JsAction, ctx: JsActionContext, at?: Point): void {
|
|
92
|
+
const owner = node.findHandler(handlerName);
|
|
93
|
+
if (owner === null && handlerName === 'onPress' && toggle(node)) return;
|
|
94
|
+
if (owner === null) throw notSupported(action, ctx, `no ${handlerName} handler on the element or its ancestors (gesture-handler gestures need native mode)`);
|
|
95
|
+
if (isDisabled(node, owner)) return;
|
|
96
|
+
call(owner, 'onPressIn', pressEvent(node, 'pressIn', at));
|
|
97
|
+
owner.fn(pressEvent(node, handlerName === 'onPress' ? 'press' : 'longPress', at));
|
|
98
|
+
call(owner, 'onPressOut', pressEvent(node, 'pressOut', at));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The topmost element at the window point `p` (hit testing like a real touch). */
|
|
102
|
+
function elementAt(p: Point, action: JsAction, ctx: JsActionContext): HostNode {
|
|
103
|
+
const top = ctx.roots === undefined ? null : hitTest(ctx.roots(), p);
|
|
104
|
+
if (top === null) throw notSupported(action, ctx, `no element at (${Math.round(p.x)}, ${Math.round(p.y)})`);
|
|
105
|
+
return top;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `point` inside `node` → the topmost element there (a child may be on top of the target). */
|
|
109
|
+
function pressAt(node: HostNode, point: Point | undefined, handler: 'onPress' | 'onLongPress', action: JsAction, ctx: JsActionContext): void {
|
|
110
|
+
if (point === undefined) return press(node, handler, action, ctx);
|
|
111
|
+
const r = node.measure();
|
|
112
|
+
const at = { x: (r?.x ?? 0) + point.x, y: (r?.y ?? 0) + point.y };
|
|
113
|
+
press(elementAt(at, action, ctx), handler, action, ctx, at);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function tap(node: HostNode, ctx: JsActionContext, point?: Point): void {
|
|
117
|
+
pressAt(node, point, 'onPress', 'tap', ctx);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function longPress(node: HostNode, ctx: JsActionContext, point?: Point): void {
|
|
121
|
+
pressAt(node, point, 'onLongPress', 'longPress', ctx);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function multiTap(node: HostNode, count: number, ctx: JsActionContext): void {
|
|
125
|
+
for (let i = 0; i < count; i++) press(node, 'onPress', count === 2 ? 'doubleTap' : 'multiTap', ctx);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function tapAt(p: Point, long: boolean, ctx: JsActionContext): void {
|
|
129
|
+
press(elementAt(p, 'tapAt', ctx), long ? 'onLongPress' : 'onPress', 'tapAt', ctx, p);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function changeText(node: HostNode, action: JsAction, next: (current: string) => string, ctx: JsActionContext): void {
|
|
133
|
+
const owner = node.findHandler('onChangeText') ?? node.findHandler('onChange');
|
|
134
|
+
if (owner === null) throw notSupported(action, ctx, 'no onChangeText / onChange handler on the element or its ancestors');
|
|
135
|
+
if (owner.props['editable'] === false || owner.props['readOnly'] === true) return;
|
|
136
|
+
const v = owner.props['value'] ?? owner.props['defaultValue'];
|
|
137
|
+
const text = next(typeof v === 'string' ? v : '');
|
|
138
|
+
call(owner, 'onFocus', { nativeEvent: { text: typeof v === 'string' ? v : '' } });
|
|
139
|
+
call(owner, 'onChange', { nativeEvent: { text, eventCount: 0, target: 0 } });
|
|
140
|
+
call(owner, 'onChangeText', text);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function typeText(node: HostNode, text: string, ctx: JsActionContext): void {
|
|
144
|
+
changeText(node, 'type', (current) => current + text, ctx);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function clearText(node: HostNode, ctx: JsActionContext): void {
|
|
148
|
+
changeText(node, 'clear', () => '', ctx);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function replaceText(node: HostNode, text: string, ctx: JsActionContext): void {
|
|
152
|
+
changeText(node, 'replaceText', () => text, ctx);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function pressKey(node: HostNode, key: KeyName, ctx: JsActionContext): void {
|
|
156
|
+
const input = node.findHandler('onChangeText') ?? node.findHandler('onSubmitEditing') ?? node.findHandler('onKeyPress');
|
|
157
|
+
if (input === null) throw notSupported('pressKey', ctx, 'no text input (onChangeText / onSubmitEditing / onKeyPress) on the element or its ancestors');
|
|
158
|
+
const value = input.props['value'] ?? input.props['defaultValue'];
|
|
159
|
+
const text = typeof value === 'string' ? value : '';
|
|
160
|
+
call(input, 'onKeyPress', { nativeEvent: { key: key === 'enter' ? 'Enter' : 'Backspace' } });
|
|
161
|
+
if (key === 'backspace') {
|
|
162
|
+
if (typeof input.props['onChangeText'] === 'function' || typeof input.props['onChange'] === 'function') changeText(node, 'pressKey', (t) => t.slice(0, -1), ctx);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (typeof input.props['onSubmitEditing'] === 'function') {
|
|
166
|
+
call(input, 'onSubmitEditing', { nativeEvent: { text } });
|
|
167
|
+
} else if (input.props['multiline'] === true) {
|
|
168
|
+
changeText(node, 'pressKey', (t) => `${t}\n`, ctx);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface Scrollable {
|
|
173
|
+
/** Current content offset. */
|
|
174
|
+
offset(): { x: number; y: number };
|
|
175
|
+
/** Size of the scroll viewport. */
|
|
176
|
+
viewport(): Rect | null;
|
|
177
|
+
/** The content container (its size is the scrollable extent). */
|
|
178
|
+
content(): Rect | null;
|
|
179
|
+
scrollTo(x: number, y: number): void;
|
|
180
|
+
/** The native scroll view host (its view tag hides the indicators). */
|
|
181
|
+
host(): HostNode | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The ScrollView that contains `node` (or is `node`), or null. */
|
|
185
|
+
export function findScrollable(node: HostNode): Scrollable | null {
|
|
186
|
+
const inst = node.findInstance('scrollTo');
|
|
187
|
+
if (inst === null) return null;
|
|
188
|
+
// ScrollView renders: native scroll view host → content container host → children.
|
|
189
|
+
const host = (): { scroll: HostNode | null; content: HostNode | null } => {
|
|
190
|
+
const scroll = inst.hostNodes()[0] ?? null;
|
|
191
|
+
return { scroll, content: scroll?.children[0] ?? null };
|
|
192
|
+
};
|
|
193
|
+
return {
|
|
194
|
+
offset() {
|
|
195
|
+
const { scroll, content } = host();
|
|
196
|
+
const s = scroll?.measure();
|
|
197
|
+
const c = content?.measure();
|
|
198
|
+
if (s == null || c == null) return { x: 0, y: 0 };
|
|
199
|
+
return { x: s.x - c.x, y: s.y - c.y };
|
|
200
|
+
},
|
|
201
|
+
viewport: () => host().scroll?.measure() ?? null,
|
|
202
|
+
content: () => host().content?.measure() ?? null,
|
|
203
|
+
host: () => host().scroll,
|
|
204
|
+
scrollTo(x, y) {
|
|
205
|
+
inst.call({ x: Math.max(0, x), y: Math.max(0, y), animated: false });
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Fraction of the viewport moved by one scroll / swipe step when no distance is given. */
|
|
211
|
+
export const STEP_FRACTION = 0.8;
|
|
212
|
+
|
|
213
|
+
/** `down` reveals content further down (offset grows), like Detox's scroll direction. */
|
|
214
|
+
export function scrollBy(s: Scrollable, direction: Direction, distance?: number): void {
|
|
215
|
+
const vp = s.viewport();
|
|
216
|
+
const vertical = direction === 'up' || direction === 'down';
|
|
217
|
+
const d = distance ?? (vp === null ? 300 : (vertical ? vp.height : vp.width) * STEP_FRACTION);
|
|
218
|
+
const { x, y } = s.offset();
|
|
219
|
+
const sign = direction === 'down' || direction === 'right' ? 1 : -1;
|
|
220
|
+
s.scrollTo(vertical ? x : x + sign * d, vertical ? y + sign * d : y);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Jump to an edge of the current content (a virtualized list may grow afterwards; callers repeat). */
|
|
224
|
+
export function scrollToEdge(s: Scrollable, edge: Edge): void {
|
|
225
|
+
const vp = s.viewport();
|
|
226
|
+
const content = s.content();
|
|
227
|
+
const { x, y } = s.offset();
|
|
228
|
+
const maxX = vp === null || content === null ? x : Math.max(0, content.width - vp.width);
|
|
229
|
+
const maxY = vp === null || content === null ? y : Math.max(0, content.height - vp.height);
|
|
230
|
+
if (edge === 'top') s.scrollTo(x, 0);
|
|
231
|
+
else if (edge === 'bottom') s.scrollTo(x, maxY);
|
|
232
|
+
else if (edge === 'left') s.scrollTo(0, y);
|
|
233
|
+
else s.scrollTo(maxX, y);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A swipe moves the finger; the content scrolls the opposite way (swipe up = scroll down). */
|
|
237
|
+
export function swipeToScrollDirection(direction: Direction): Direction {
|
|
238
|
+
return ({ up: 'down', down: 'up', left: 'right', right: 'left' } as const)[direction];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function requireScrollable(node: HostNode, action: 'scroll' | 'swipe' | 'scrollTo', ctx: JsActionContext): Scrollable {
|
|
242
|
+
const s = findScrollable(node);
|
|
243
|
+
if (s === null) throw notSupported(action, ctx, 'the element is not inside a ScrollView / FlatList');
|
|
244
|
+
return s;
|
|
245
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-finger gestures (Phase 6.4): finger paths, pinch / rotate geometry and the frame format the
|
|
3
|
+
* native modules and the web host play back.
|
|
4
|
+
*
|
|
5
|
+
* Frames (flat numbers, `1 + 3 × fingers` per frame): `[tMs, down0, x0, y0, down1, x1, y1, ...]`, window
|
|
6
|
+
* coordinates, t from 0. A finger goes down in the first frame it is down and lifts in the first frame
|
|
7
|
+
* it is not; the last frame has every finger up (players also lift any finger still down after it).
|
|
8
|
+
*/
|
|
9
|
+
import type { Point, Rect, Speed } from '@quiesco/protocol';
|
|
10
|
+
|
|
11
|
+
import type { Viewport } from '../fiber-adapter';
|
|
12
|
+
|
|
13
|
+
/** One finger: window points with the time (ms from the gesture's start) it reaches them. */
|
|
14
|
+
export type FingerPath = [Point, number][];
|
|
15
|
+
|
|
16
|
+
/** Frame interval: one per display frame, like a touchscreen's report rate. */
|
|
17
|
+
export const FRAME_MS = 16;
|
|
18
|
+
/** Fingers rest before moving (the recognizers see both touches) and before lifting (no fling). */
|
|
19
|
+
export const PRESS_MS = 80;
|
|
20
|
+
export const REST_MS = 150;
|
|
21
|
+
/** Fingers closer than this merge on a real screen; pinches keep them apart. */
|
|
22
|
+
export const MIN_RADIUS = 20;
|
|
23
|
+
/** Keep fingers this far inside the window. */
|
|
24
|
+
const EDGE = 8;
|
|
25
|
+
|
|
26
|
+
const PINCH_MS: Record<Speed, number> = { slow: 1200, normal: 600, fast: 300 };
|
|
27
|
+
/** Rotation time per radian. */
|
|
28
|
+
const ROTATE_MS_PER_RAD: Record<Speed, number> = { slow: 800, normal: 400, fast: 200 };
|
|
29
|
+
|
|
30
|
+
function at(path: FingerPath, t: number): Point {
|
|
31
|
+
const first = path[0] as [Point, number];
|
|
32
|
+
if (t <= first[1]) return first[0];
|
|
33
|
+
for (let k = 1; k < path.length; k++) {
|
|
34
|
+
const [b, tb] = path[k] as [Point, number];
|
|
35
|
+
if (t <= tb) {
|
|
36
|
+
const [a, ta] = path[k - 1] as [Point, number];
|
|
37
|
+
const f = tb === ta ? 1 : (t - ta) / (tb - ta);
|
|
38
|
+
return { x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return (path[path.length - 1] as [Point, number])[0];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Frames for fingers that may start and end at different times (each is down from its first to its last point). */
|
|
45
|
+
export function framesFor(fingers: readonly FingerPath[], stepMs = FRAME_MS): number[] {
|
|
46
|
+
const spans = fingers.map((p) => [(p[0] as [Point, number])[1], (p[p.length - 1] as [Point, number])[1]] as const);
|
|
47
|
+
const times = new Set<number>();
|
|
48
|
+
const start = Math.min(...spans.map((s) => s[0]));
|
|
49
|
+
const end = Math.max(...spans.map((s) => s[1]));
|
|
50
|
+
for (let t = start; t < end; t += stepMs) times.add(Math.round(t));
|
|
51
|
+
for (const [s, e] of spans) {
|
|
52
|
+
times.add(s);
|
|
53
|
+
times.add(e);
|
|
54
|
+
times.add(e + 1); // the frame where a finger that ends early lifts
|
|
55
|
+
}
|
|
56
|
+
const out: number[] = [];
|
|
57
|
+
for (const t of [...times].sort((a, b) => a - b)) {
|
|
58
|
+
if (t > end + 1) continue;
|
|
59
|
+
out.push(t - start);
|
|
60
|
+
fingers.forEach((path, i) => {
|
|
61
|
+
const [s, e] = spans[i] as readonly [number, number];
|
|
62
|
+
if (t < s || t > e) {
|
|
63
|
+
out.push(0, 0, 0);
|
|
64
|
+
} else {
|
|
65
|
+
const p = at(path, t);
|
|
66
|
+
out.push(1, p.x, p.y);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Gestures one after another (all fingers up in between, `gapMs` apart), as one frame list. */
|
|
74
|
+
export function framesForSequence(gestures: readonly (readonly FingerPath[])[], gapMs = 120): number[] {
|
|
75
|
+
const n = Math.max(...gestures.map((g) => g.length));
|
|
76
|
+
const out: number[] = [];
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (const g of gestures) {
|
|
79
|
+
const frames = framesFor(g);
|
|
80
|
+
const own = 1 + 3 * g.length;
|
|
81
|
+
let last = 0;
|
|
82
|
+
for (let k = 0; k < frames.length; k += own) {
|
|
83
|
+
last = (frames[k] as number) + offset;
|
|
84
|
+
out.push(last);
|
|
85
|
+
for (let i = 0; i < n; i++) {
|
|
86
|
+
if (i < g.length) out.push(frames[k + 1 + 3 * i] as number, frames[k + 2 + 3 * i] as number, frames[k + 3 + 3 * i] as number);
|
|
87
|
+
else out.push(0, 0, 0);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// framesFor ends with every finger up (the frame after the last end).
|
|
91
|
+
offset = last + gapMs;
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const center = (r: Rect): Point => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 });
|
|
97
|
+
|
|
98
|
+
/** How far from `c` a finger can go along direction (dx, dy) and stay in the window. */
|
|
99
|
+
function reach(c: Point, dx: number, dy: number, viewport: Viewport | null): number {
|
|
100
|
+
if (viewport === null) return Number.POSITIVE_INFINITY;
|
|
101
|
+
let r = Number.POSITIVE_INFINITY;
|
|
102
|
+
for (const [d, pos, size] of [
|
|
103
|
+
[dx, c.x, viewport.width],
|
|
104
|
+
[dy, c.y, viewport.height],
|
|
105
|
+
] as const) {
|
|
106
|
+
if (d > 1e-9) r = Math.min(r, (size - EDGE - pos) / d);
|
|
107
|
+
else if (d < -1e-9) r = Math.min(r, (pos - EDGE) / -d);
|
|
108
|
+
}
|
|
109
|
+
return r;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Half the element's extent along the direction, inset: where fingers can go down on it. */
|
|
113
|
+
function inside(rect: Rect, dx: number, dy: number): number {
|
|
114
|
+
const hx = Math.abs(dx) < 1e-9 ? Number.POSITIVE_INFINITY : rect.width / 2 / Math.abs(dx);
|
|
115
|
+
const hy = Math.abs(dy) < 1e-9 ? Number.POSITIVE_INFINITY : rect.height / 2 / Math.abs(dy);
|
|
116
|
+
return Math.min(hx, hy) * 0.85;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function twoFingers(c: Point, dx: number, dy: number, rStart: number, r0: number, r1: number, moveMs: number): FingerPath[] {
|
|
120
|
+
const p = (r: number, s: number): Point => ({ x: c.x + s * r * dx, y: c.y + s * r * dy });
|
|
121
|
+
// Lead-in (rStart → r0) at the same speed as the counted part.
|
|
122
|
+
const leadMs = Math.abs(r1 - r0) < 1e-9 ? 0 : (moveMs * Math.abs(r0 - rStart)) / Math.abs(r1 - r0);
|
|
123
|
+
return [1, -1].map((s) => [
|
|
124
|
+
[p(rStart, s), 0],
|
|
125
|
+
[p(rStart, s), PRESS_MS],
|
|
126
|
+
[p(r0, s), PRESS_MS + leadMs],
|
|
127
|
+
[p(r1, s), PRESS_MS + leadMs + moveMs],
|
|
128
|
+
[p(r1, s), PRESS_MS + leadMs + moveMs + REST_MS],
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Pinches that scale the distance between two fingers by `scale` in total, centered on the element, the
|
|
134
|
+
* fingers on a line at `angle` (radians, 0 = horizontal). The fingers go down on the element and stay in
|
|
135
|
+
* the window; a factor that does not fit in one pinch is split into equal pinches (as a user repeats).
|
|
136
|
+
* `slop`: the distance change before the app's recognizer activates (and starts measuring); each pinch
|
|
137
|
+
* moves that much first, so the recognizer reports `scale`.
|
|
138
|
+
*/
|
|
139
|
+
export function pinchGestures(rect: Rect, scale: number, angle: number, speed: Speed, viewport: Viewport | null, slop = 0): FingerPath[][] {
|
|
140
|
+
const c = center(rect);
|
|
141
|
+
const dx = Math.cos(angle);
|
|
142
|
+
const dy = Math.sin(angle);
|
|
143
|
+
const half = inside(rect, dx, dy);
|
|
144
|
+
const maxR = Math.min(reach(c, dx, dy, viewport), reach(c, -dx, -dy, viewport));
|
|
145
|
+
const lead = slop / 2; // per finger
|
|
146
|
+
if (half < MIN_RADIUS * 1.2 + lead || maxR < MIN_RADIUS * 1.2 + lead) {
|
|
147
|
+
throw new RangeError(`the element (${Math.round(rect.width)}×${Math.round(rect.height)}) is too small for two fingers`);
|
|
148
|
+
}
|
|
149
|
+
// Zoom in: start close together on the element; zoom out: start wide apart on it.
|
|
150
|
+
const zoomIn = scale >= 1;
|
|
151
|
+
const rStart = zoomIn ? Math.max(MIN_RADIUS, half * 0.4 - lead) : Math.min(half, maxR);
|
|
152
|
+
const r0 = zoomIn ? rStart + lead : rStart - lead;
|
|
153
|
+
const limit = zoomIn ? maxR / r0 : MIN_RADIUS / r0;
|
|
154
|
+
const n = Math.max(1, Math.ceil(Math.log(scale) / Math.log(limit) - 1e-9));
|
|
155
|
+
const f = scale ** (1 / n);
|
|
156
|
+
const moveMs = PINCH_MS[speed];
|
|
157
|
+
return Array.from({ length: n }, () => twoFingers(c, dx, dy, rStart, r0, r0 * f, moveMs));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Two fingers turning `radians` (positive: clockwise on screen) around the element's center, on a
|
|
162
|
+
* circle that fits the element; sampled every frame so the fingers follow the arc. `slop`: the
|
|
163
|
+
* rotation before the app's recognizer activates, added to the turn.
|
|
164
|
+
*/
|
|
165
|
+
export function rotateGesture(rect: Rect, radians: number, speed: Speed, slop = 0): FingerPath[] {
|
|
166
|
+
const c = center(rect);
|
|
167
|
+
const r = Math.max(MIN_RADIUS * 1.5, Math.min(rect.width, rect.height) / 2 * 0.6);
|
|
168
|
+
// The recognizer measures from where it activates: turn `slop` more (same direction) in total.
|
|
169
|
+
const total = radians + Math.sign(radians) * slop;
|
|
170
|
+
const moveMs = Math.max(250, Math.abs(total) * ROTATE_MS_PER_RAD[speed]);
|
|
171
|
+
const steps = Math.max(2, Math.ceil(moveMs / FRAME_MS));
|
|
172
|
+
return [0, Math.PI].map((phase) => {
|
|
173
|
+
const p = (a: number): Point => ({ x: c.x + r * Math.cos(phase + a), y: c.y + r * Math.sin(phase + a) });
|
|
174
|
+
const path: FingerPath = [[p(0), 0]];
|
|
175
|
+
for (let k = 0; k <= steps; k++) path.push([p((total * k) / steps), PRESS_MS + (moveMs * k) / steps]);
|
|
176
|
+
path.push([p(total), PRESS_MS + moveMs + REST_MS]);
|
|
177
|
+
return path;
|
|
178
|
+
});
|
|
179
|
+
}
|