mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
f32659df31
Adds a selector-targeted pinch-zoom directive to flows: `pinch: { on?,
scale }`. `on` resolves like `tap` (settled tree + auto-wait); omitted,
the pinch centers on the screen. `scale > 1` zooms in, `< 1` zooms out.
## Design
**Options-map only, no bare form.** `tap: "Map"` is unambiguous; `pinch:
"Map"` isn't (in or out?), so the parser requires `{ on?, scale }` and
rejects a bare selector with authoring guidance.
**Validity checks, not policy caps.** `scale` must be finite, > 0, and ≠
1 (a no-op scale is almost certainly a mistake) — but there is
deliberately no magnitude cap. An extreme scale decomposes
logarithmically into equal-ratio sub-gestures of ≤4× each, chained with
a 250 ms recognizer-reset delay (`scale: 1e6` is just 10 gestures).
**Pure geometry module.** All placement math lives in
`flow-pinch-geometry.ts` as deterministic data-in/data-out functions (no
device access), so tests drive it with synthetic centers, spans, and
edge guards. Per sub-gesture, a horizontal and a vertical candidate are
built from the axis-matching target dimension (normalized X and Y are
different units, so never `min(width, height)`), and the better one is
dispatched.
**Edge safety scores, it never rejects.** Per-platform system-edge
guards (wider on Android's back-gesture zones) demote candidates in
ranking but never fail a step: viable travel beats edge safety (a
risky-but-perceptible gesture beats a safe no-op the recognizer
ignores), then fully-safe with the most travel, then motion parallel to
the violated edge (edge swipes need motion *away* from the edge). Guards
are resolved once per directive and passed as data — the seam for a
future per-device query. The only geometry failure is literally zero
on-screen finger travel; a tiny target is still attempted.
**Centroid drift near edges.** When expanding fingers would leave the
screen, the centroid drifts inward — only on the moving axis, and only
as far as needed. `gesture-pinch` grows optional
`endCenterX`/`endCenterY` to interpolate the centroid alongside
separation; omitted, behavior is unchanged.
**Open-loop by design.** There is no "current zoom" to read back, so the
directive doesn't try to verify the multiplier — flows assert on the
resulting UI instead.
**Platform rejections.** Vega is remote-driven (no touch input) and
Chromium has no uniform pinch-zoom mapping for a desktop app (ctrl+wheel
or app-specific controls) — both fail upfront with guidance to drive the
app's zoom UI directly.
## Tests
Unit tests cover the geometry module (decomposition, candidate
construction, edge-guard ranking), the directive end-to-end against a
fake device, and the `gesture-pinch` centroid-drift interpolation.
253 lines
9.6 KiB
TypeScript
253 lines
9.6 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildAxisCandidate,
|
||
decomposePinch,
|
||
selectPinchCandidate,
|
||
systemEdgeGuards,
|
||
PINCH_RATIO_PER_GESTURE,
|
||
SCREEN_EDGE_INSET,
|
||
type EdgeGuards,
|
||
type PinchCandidate,
|
||
} from "../../src/tools/flows/flow-pinch-geometry";
|
||
import { resolveDevice } from "../../src/utils/device-info";
|
||
|
||
const IOS_GUARDS = systemEdgeGuards(resolveDevice("00000000-0000-0000-0000-0000000000ab"));
|
||
const ANDROID_GUARDS = systemEdgeGuards(resolveDevice("emulator-5554"));
|
||
|
||
/** Build both axis candidates and select, like runPinch does. */
|
||
function pick(
|
||
center: { x: number; y: number },
|
||
per: number,
|
||
guards: EdgeGuards,
|
||
frame?: { width: number; height: number }
|
||
): PinchCandidate | undefined {
|
||
const candidates = [
|
||
buildAxisCandidate({ angle: 0, center, targetSpan: frame?.width, per, guards }),
|
||
buildAxisCandidate({ angle: 90, center, targetSpan: frame?.height, per, guards }),
|
||
].filter((c): c is PinchCandidate => c !== undefined);
|
||
return selectPinchCandidate(candidates);
|
||
}
|
||
|
||
describe("decomposePinch", () => {
|
||
it("splits by the per-gesture ratio cap only, with equal-ratio sub-gestures", () => {
|
||
expect(decomposePinch(2)).toEqual({ n: 1, per: 2 });
|
||
expect(decomposePinch(0.5)).toEqual({ n: 1, per: 0.5 });
|
||
|
||
const zoomIn = decomposePinch(20);
|
||
expect(zoomIn.n).toBe(3);
|
||
expect(zoomIn.per).toBeCloseTo(20 ** (1 / 3), 12);
|
||
|
||
const zoomOut = decomposePinch(0.05);
|
||
expect(zoomOut.n).toBe(3);
|
||
expect(zoomOut.per).toBeCloseTo(0.05 ** (1 / 3), 12);
|
||
});
|
||
|
||
it("grows logarithmically with no gesture-count cap (scale 1e6 → 10 gestures)", () => {
|
||
expect(decomposePinch(1e6).n).toBe(10);
|
||
});
|
||
|
||
it("decomposes extreme scales finitely — a subnormal scale must not overflow via 1/scale", () => {
|
||
// 5e-324 (Number.MIN_VALUE) is exactly 4^-537: the reciprocal is Infinity,
|
||
// but the log-space magnitude still decomposes it.
|
||
const tiny = decomposePinch(5e-324);
|
||
expect(tiny.n).toBe(537);
|
||
expect(Number.isFinite(tiny.per)).toBe(true);
|
||
expect(tiny.per).toBeGreaterThan(0);
|
||
expect(tiny.per).toBeLessThan(1);
|
||
expect(tiny.per ** tiny.n).toBe(5e-324);
|
||
|
||
const huge = decomposePinch(1e300);
|
||
expect(huge.n).toBe(499);
|
||
expect(huge.per).toBeCloseTo(1e300 ** (1 / 499), 12);
|
||
});
|
||
|
||
it("does not gain a spurious gesture on exact ratio powers", () => {
|
||
expect(decomposePinch(4).n).toBe(1);
|
||
expect(decomposePinch(16).n).toBe(2);
|
||
expect(decomposePinch(64).n).toBe(3);
|
||
expect(decomposePinch(0.25).n).toBe(1);
|
||
// The worst FP case: log(4^29)/log(4) lands epsilon-high.
|
||
expect(decomposePinch(PINCH_RATIO_PER_GESTURE ** 29).n).toBe(29);
|
||
// Mirror case: |log(4^-29)| can differ from log(4^29) by an ulp.
|
||
expect(decomposePinch(PINCH_RATIO_PER_GESTURE ** -29).n).toBe(29);
|
||
});
|
||
});
|
||
|
||
describe("buildAxisCandidate / selectPinchCandidate", () => {
|
||
it("dispatches one substantial gesture for a portrait-normalized 150×150 target at scale 4", () => {
|
||
// 150×150 px on a 1080×2400 portrait phone: much wider than tall in
|
||
// normalized units — the axis math must use the matching dimension.
|
||
const frame = { width: 150 / 1080, height: 150 / 2400 };
|
||
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, frame);
|
||
|
||
expect(selected).toBeDefined();
|
||
expect(selected!.angle).toBe(0); // width is the larger normalized span
|
||
expect(selected!.end / selected!.start).toBeCloseTo(4, 9); // full requested ratio
|
||
expect(selected!.travel).toBeGreaterThan(0.3); // substantial, not target-clipped
|
||
expect(selected!.fullyEdgeSafe).toBe(true);
|
||
});
|
||
|
||
it("chooses the vertical axis for a tall/narrow target", () => {
|
||
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, { width: 0.1, height: 0.6 });
|
||
expect(selected!.angle).toBe(90);
|
||
});
|
||
|
||
it("still attempts a tiny target (no minimum size)", () => {
|
||
const selected = pick({ x: 0.5, y: 0.5 }, 4, IOS_GUARDS, { width: 0.01, height: 0.005 });
|
||
expect(selected).toBeDefined();
|
||
expect(selected!.travel).toBeGreaterThan(0);
|
||
expect(selected!.end / selected!.start).toBeCloseTo(4, 9);
|
||
});
|
||
|
||
it("runs parallel to the violated edge for a side-edge target", () => {
|
||
// Center inside the left guard: a horizontal pinch would start like an OS
|
||
// back swipe, so the vertical (parallel) candidate must win.
|
||
const selected = pick({ x: 0.05, y: 0.5 }, 4, IOS_GUARDS, { width: 0.1, height: 0.2 });
|
||
expect(selected!.angle).toBe(90);
|
||
expect(selected!.axisSafe).toBe(true);
|
||
expect(selected!.fullyEdgeSafe).toBe(false); // perpendicular coordinate stays in the guard
|
||
});
|
||
|
||
it("keeps the ratio and drifts the centroid inward for a corner target", () => {
|
||
// Bottom-left corner (frame x 0–0.3, y 0.86–1.0): center (0.15, 0.93).
|
||
const selected = pick({ x: 0.15, y: 0.93 }, 4, IOS_GUARDS, { width: 0.3, height: 0.14 });
|
||
|
||
expect(selected!.angle).toBe(0); // parallel to the violated bottom edge
|
||
expect(selected!.end / selected!.start).toBeCloseTo(4, 9);
|
||
expect(selected!.endCenter).toBeGreaterThan(0.15); // drifted inward
|
||
// Final pointers stay inside the screen inset.
|
||
expect(selected!.endCenter - selected!.end / 2).toBeGreaterThanOrEqual(
|
||
SCREEN_EDGE_INSET - 1e-9
|
||
);
|
||
expect(selected!.endCenter + selected!.end / 2).toBeLessThanOrEqual(
|
||
1 - SCREEN_EDGE_INSET + 1e-9
|
||
);
|
||
});
|
||
|
||
it("keeps Down points within the screen inset when the centroid sits inside a guard", () => {
|
||
// Center x 0.05 is inside the 0.08 left guard: the fallback start span
|
||
// must clamp to the 2% inset, not the full screen span.
|
||
const candidate = buildAxisCandidate({
|
||
angle: 0,
|
||
center: { x: 0.05, y: 0.5 },
|
||
targetSpan: 0.1,
|
||
per: 4,
|
||
guards: IOS_GUARDS,
|
||
});
|
||
expect(candidate).toBeDefined();
|
||
expect(candidate!.start).toBeGreaterThan(0);
|
||
expect(0.05 - candidate!.start / 2).toBeGreaterThanOrEqual(SCREEN_EDGE_INSET - 1e-9);
|
||
});
|
||
|
||
it("scores a candidate with its perpendicular coordinate in a guard band as not fully edge-safe", () => {
|
||
// Own axis clear, but both Down points ride the top guard band.
|
||
const candidate = buildAxisCandidate({
|
||
angle: 0,
|
||
center: { x: 0.5, y: 0.03 },
|
||
per: 4,
|
||
guards: IOS_GUARDS,
|
||
});
|
||
expect(candidate!.axisSafe).toBe(true);
|
||
expect(candidate!.fullyEdgeSafe).toBe(false);
|
||
});
|
||
|
||
it("guards the same near-side-edge target at 0.13 on android but 0.08 on ios", () => {
|
||
expect(IOS_GUARDS).toEqual({ left: 0.08, right: 0.08, top: 0.08, bottom: 0.08 });
|
||
expect(ANDROID_GUARDS).toEqual({ left: 0.13, right: 0.13, top: 0.08, bottom: 0.08 });
|
||
|
||
// Wide target near the left edge (center x 0.15): horizontal clears the
|
||
// iOS 0.08 side guard with room to spare, but not Android's 0.13.
|
||
const center = { x: 0.15, y: 0.5 };
|
||
const frame = { width: 0.3, height: 0.1 };
|
||
expect(pick(center, 4, IOS_GUARDS, frame)!.angle).toBe(0);
|
||
expect(pick(center, 4, ANDROID_GUARDS, frame)!.angle).toBe(90);
|
||
});
|
||
|
||
it("tracks injected synthetic guards — the selection flips with the guard data alone", () => {
|
||
const center = { x: 0.5, y: 0.5 };
|
||
const sideGuarded: EdgeGuards = { left: 0.4, right: 0.4, top: 0, bottom: 0 };
|
||
const capGuarded: EdgeGuards = { left: 0, right: 0, top: 0.4, bottom: 0.4 };
|
||
expect(pick(center, 4, sideGuarded)!.angle).toBe(90);
|
||
expect(pick(center, 4, capGuarded)!.angle).toBe(0);
|
||
});
|
||
|
||
it("fails only when there is literally no positive on-screen motion", () => {
|
||
expect(selectPinchCandidate([])).toBeUndefined();
|
||
// A centroid at the screen edge has no room on its axis in the fallback.
|
||
expect(
|
||
buildAxisCandidate({ angle: 0, center: { x: 0, y: 0.5 }, per: 4, guards: IOS_GUARDS })
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it("prefers fully edge-safe candidates over axis-only safe candidates", () => {
|
||
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
|
||
angle: 0,
|
||
start: 0.1,
|
||
end: 0.13,
|
||
endCenter: 0.5,
|
||
travel: 0.03,
|
||
viable: true,
|
||
axisSafe: true,
|
||
fullyEdgeSafe: true,
|
||
clearance: -1,
|
||
...overrides,
|
||
});
|
||
const fullyEdgeSafe = mk({ angle: 90 });
|
||
const axisSafe = mk({
|
||
fullyEdgeSafe: false,
|
||
clearance: 1,
|
||
travel: 0.9,
|
||
});
|
||
|
||
// Deliberately make both later tie-breakers favor axisSafe: the safety
|
||
// boundary itself must decide this comparison.
|
||
expect(selectPinchCandidate([axisSafe, fullyEdgeSafe])).toBe(fullyEdgeSafe);
|
||
});
|
||
|
||
it("prefers axis-only safe candidates over candidates unsafe on their own axis", () => {
|
||
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
|
||
angle: 0,
|
||
start: 0.1,
|
||
end: 0.13,
|
||
endCenter: 0.5,
|
||
travel: 0.03,
|
||
viable: true,
|
||
axisSafe: true,
|
||
fullyEdgeSafe: false,
|
||
clearance: -1,
|
||
...overrides,
|
||
});
|
||
const axisSafe = mk({ angle: 90 });
|
||
const unsafe = mk({
|
||
axisSafe: false,
|
||
clearance: 1,
|
||
travel: 0.9,
|
||
});
|
||
|
||
// Deliberately make both later tie-breakers favor unsafe: the safety
|
||
// boundary itself must decide this comparison.
|
||
expect(selectPinchCandidate([unsafe, axisSafe])).toBe(axisSafe);
|
||
});
|
||
|
||
it("prefers greater starting clearance, then greater travel, among unsafe candidates", () => {
|
||
const mk = (overrides: Partial<PinchCandidate>): PinchCandidate => ({
|
||
angle: 0,
|
||
start: 0.1,
|
||
end: 0.4,
|
||
endCenter: 0.5,
|
||
travel: 0.3,
|
||
viable: true,
|
||
axisSafe: false,
|
||
fullyEdgeSafe: false,
|
||
clearance: -0.05,
|
||
...overrides,
|
||
});
|
||
expect(
|
||
selectPinchCandidate([mk({ clearance: -0.06 }), mk({ angle: 90, clearance: -0.01 })])!.angle
|
||
).toBe(90);
|
||
expect(selectPinchCandidate([mk({ travel: 0.1 }), mk({ angle: 90, travel: 0.5 })])!.angle).toBe(
|
||
90
|
||
);
|
||
});
|
||
});
|