mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
6a53e66a96
# feat(flow): rotate directive
Adds a selector-targeted two-finger rotation directive to flows:
`rotate: { on?, by }`. `on` resolves like `tap` (settled tree +
auto-wait); omitted, the rotation centers on the screen. `by` is
degrees, positive = clockwise. Distinct from `tool: rotate`, which
changes device orientation — the docs and step labels call this out.
## Design
**One continuous gesture, fixed pace, no duration option.** Fingers
orbit at ~90°/300 ms; duration derives from the angle. Unlike pinch
there is no sub-gesture chaining — any angle dispatches as one sweep, so
the angular delta is exact with no settle delays. `by` is parse-bounded
to ±3000°: the largest sweep the 10 s single-gesture envelope delivers
at that pace, derived from the constants rather than picked as policy —
rejected at parse, never silently clamped. A consequence embraced here:
frame count always scales with the angle, so a speed-sensitive recording
stays a raw `tool:` step (the create-flow skill's polish list covers the
conversion).
**Fixed centroid, constant physical radius.** Drifting the centroid
would couple pan into the turn; growing the radius would couple pinch.
Both stay fixed — the trade is a smaller orbit near edges and small
targets, in exchange for a pure rotation.
**Physical circle, not a normalized-space ellipse.** A single normalized
radius on a non-square screen traces a physical ellipse, coupling an
unintended pinch into every turn. `gesture-rotate` grows optional
per-axis `radiusX`/`radiusY` (fractions of width/height with `radiusX·W
= radiusY·H`), and the flow tree adapters surface screen dimensions via
an optional `screen` field on `DescribeTreeData` so the directive can
compute the aspect. Missing dimensions fail soft to the legacy
single-radius call rather than erroring.
**Placement reuses pinch's edge model.** The initial finger axis
(horizontal or vertical) is scored against the same per-platform
system-edge guards, preferring a start parallel to the nearest violated
edge and staying inside the target's axis-matching span. Guards only
score placement — they never shrink the radius, and a violated guard is
attempted, never rejected. The only geometry failure is no positive
on-screen orbit radius.
**Abort correctness.** `gesture-rotate` now honors `ctx.signal`: each
frame checks it, lifts the synthetic fingers with a terminal Up (so a
cancelled run doesn't leave them held on the glass), then throws with
name `AbortError`; the directive maps a mid-gesture abort to the run's
aborted skip rather than a step failure.
**Explicit `inputSchema`.** The radius requirement is a zod `.refine()`
cross-field rule, which `z.toJSONSchema` drops — so the tool ships a
derived schema plus an explicit `anyOf` (`radius` or
`radiusX`+`radiusY`) to keep invalid calls rejected at the schema layer.
**Platform rejections.** Vega is remote-driven (no touch) and Chromium
has no rotate-gesture idiom at all — both fail upfront with guidance to
drive the app's rotate controls directly.
## Tests
Unit tests cover the geometry module (duration derivation, candidate
construction, aspect handling, edge-guard ranking), the directive
end-to-end against a fake device, and `gesture-rotate`'s per-axis radii
and abort path.
194 lines
7.5 KiB
TypeScript
194 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildRotateCandidate,
|
|
deriveRotateDurationMs,
|
|
selectRotateCandidate,
|
|
MAX_DERIVED_ROTATE_MS,
|
|
MAX_ROTATE_BY_DEG,
|
|
type RotateCandidate,
|
|
} from "../../src/tools/flows/flow-rotate-geometry";
|
|
import {
|
|
systemEdgeGuards,
|
|
SCREEN_EDGE_INSET,
|
|
TARGET_START_FRACTION,
|
|
type EdgeGuards,
|
|
} 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"));
|
|
|
|
// Synthetic portrait phone: normalized X and Y are very different units.
|
|
const SCREEN_W = 1080;
|
|
const SCREEN_H = 2400;
|
|
const ASPECT = SCREEN_W / SCREEN_H;
|
|
|
|
/** Build both start-axis candidates and select, like runRotate does. */
|
|
function pick(
|
|
center: { x: number; y: number },
|
|
guards: EdgeGuards,
|
|
frame?: { width: number; height: number },
|
|
aspect = ASPECT
|
|
): RotateCandidate | undefined {
|
|
const candidates = [
|
|
buildRotateCandidate({ startAngle: 0, center, targetSpan: frame?.width, guards, aspect }),
|
|
buildRotateCandidate({ startAngle: 90, center, targetSpan: frame?.height, guards, aspect }),
|
|
].filter((c): c is RotateCandidate => c !== undefined);
|
|
return selectRotateCandidate(candidates);
|
|
}
|
|
|
|
describe("deriveRotateDurationMs", () => {
|
|
it("follows ~90°/300ms with a floor of one 90° unit", () => {
|
|
expect(deriveRotateDurationMs(90)).toBe(300);
|
|
expect(deriveRotateDurationMs(720)).toBe(2400);
|
|
expect(deriveRotateDurationMs(-720)).toBe(2400);
|
|
expect(deriveRotateDurationMs(45)).toBe(300); // max(1, |by|/90) floor
|
|
});
|
|
|
|
it("stays linear up to the parse-bounded maximum sweep", () => {
|
|
// No runtime cap: the parse bound on `by` keeps the derive inside the
|
|
// single-gesture envelope by construction.
|
|
expect(MAX_ROTATE_BY_DEG).toBe(3000);
|
|
expect(deriveRotateDurationMs(MAX_ROTATE_BY_DEG)).toBe(MAX_DERIVED_ROTATE_MS);
|
|
expect(deriveRotateDurationMs(-MAX_ROTATE_BY_DEG)).toBe(MAX_DERIVED_ROTATE_MS);
|
|
});
|
|
});
|
|
|
|
describe("buildRotateCandidate / selectRotateCandidate", () => {
|
|
it("produces a physically circular orbit: radiusX·W equals radiusY·H", () => {
|
|
for (const frame of [undefined, { width: 0.8, height: 0.4 }, { width: 0.1, height: 0.6 }]) {
|
|
const selected = pick({ x: 0.5, y: 0.5 }, IOS_GUARDS, frame)!;
|
|
expect(selected.radiusX * SCREEN_W).toBeCloseTo(selected.radiusY * SCREEN_H, 9);
|
|
}
|
|
});
|
|
|
|
it("keeps both initial points inside the target on the chosen axis", () => {
|
|
const frame = { width: 0.3, height: 0.2 };
|
|
const selected = pick({ x: 0.5, y: 0.5 }, IOS_GUARDS, frame)!;
|
|
const separation = selected.startAngle === 0 ? 2 * selected.radiusX : 2 * selected.radiusY;
|
|
const span = selected.startAngle === 0 ? frame.width : frame.height;
|
|
expect(separation).toBeLessThanOrEqual(span * TARGET_START_FRACTION + 1e-9);
|
|
});
|
|
|
|
it("keeps every orbit point within the screen inset on both axes", () => {
|
|
for (const center of [
|
|
{ x: 0.5, y: 0.5 },
|
|
{ x: 0.2, y: 0.8 },
|
|
{ x: 0.9, y: 0.1 },
|
|
]) {
|
|
const selected = pick(center, IOS_GUARDS)!;
|
|
for (let deg = 0; deg < 360; deg += 15) {
|
|
const rad = (deg * Math.PI) / 180;
|
|
const x = center.x + selected.radiusX * Math.cos(rad);
|
|
const y = center.y + selected.radiusY * Math.sin(rad);
|
|
expect(x).toBeGreaterThanOrEqual(SCREEN_EDGE_INSET - 1e-9);
|
|
expect(x).toBeLessThanOrEqual(1 - SCREEN_EDGE_INSET + 1e-9);
|
|
expect(y).toBeGreaterThanOrEqual(SCREEN_EDGE_INSET - 1e-9);
|
|
expect(y).toBeLessThanOrEqual(1 - SCREEN_EDGE_INSET + 1e-9);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("chooses the vertical start for a tall/narrow target (bigger physical orbit)", () => {
|
|
const selected = pick({ x: 0.5, y: 0.5 }, IOS_GUARDS, { width: 0.1, height: 0.6 })!;
|
|
expect(selected.startAngle).toBe(90);
|
|
});
|
|
|
|
it("starts vertical near a left edge and horizontal near the top", () => {
|
|
// Left-edge target: horizontal Down points would sit in the back-swipe zone.
|
|
const left = pick({ x: 0.06, y: 0.5 }, IOS_GUARDS, { width: 0.1, height: 0.2 })!;
|
|
expect(left.startAngle).toBe(90);
|
|
expect(left.axisSafe).toBe(true);
|
|
expect(left.fullyEdgeSafe).toBe(false); // perpendicular coordinate stays in the guard
|
|
|
|
// Top-edge target: vertical Down points would sit in the shade-pull zone.
|
|
const top = pick({ x: 0.5, y: 0.06 }, IOS_GUARDS, { width: 0.2, height: 0.08 })!;
|
|
expect(top.startAngle).toBe(0);
|
|
expect(top.axisSafe).toBe(true);
|
|
expect(top.fullyEdgeSafe).toBe(false);
|
|
});
|
|
|
|
it("guards the same near-side-edge target at 0.13 on android but 0.08 on ios", () => {
|
|
// Wide, short target at x 0.2: horizontal Down points (0.11 / 0.29) clear
|
|
// the iOS 0.08 side guard — and win on orbit size — but not Android's 0.13.
|
|
const center = { x: 0.2, y: 0.5 };
|
|
const frame = { width: 0.2, height: 0.04 };
|
|
expect(pick(center, IOS_GUARDS, frame)!.startAngle).toBe(0);
|
|
expect(pick(center, ANDROID_GUARDS, frame)!.startAngle).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, sideGuarded)!.startAngle).toBe(90);
|
|
expect(pick(center, capGuarded)!.startAngle).toBe(0);
|
|
});
|
|
|
|
it("still attempts a tiny target (no minimum size)", () => {
|
|
const selected = pick({ x: 0.5, y: 0.5 }, IOS_GUARDS, { width: 0.01, height: 0.005 });
|
|
expect(selected).toBeDefined();
|
|
expect(selected!.radiusX).toBeGreaterThan(0);
|
|
expect(selected!.radiusX * SCREEN_W).toBeCloseTo(selected!.radiusY * SCREEN_H, 9);
|
|
});
|
|
|
|
it("fails only when no positive on-screen radius exists", () => {
|
|
expect(selectRotateCandidate([])).toBeUndefined();
|
|
// A centroid inside the screen inset: every circle would cross it.
|
|
expect(
|
|
buildRotateCandidate({
|
|
startAngle: 0,
|
|
center: { x: 0.01, y: 0.5 },
|
|
guards: IOS_GUARDS,
|
|
aspect: ASPECT,
|
|
})
|
|
).toBeUndefined();
|
|
expect(
|
|
buildRotateCandidate({
|
|
startAngle: 90,
|
|
center: { x: 0.01, y: 0.5 },
|
|
guards: IOS_GUARDS,
|
|
aspect: ASPECT,
|
|
})
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("shares one radius bound across both candidates when there is no target", () => {
|
|
const h = buildRotateCandidate({
|
|
startAngle: 0,
|
|
center: { x: 0.5, y: 0.5 },
|
|
guards: IOS_GUARDS,
|
|
aspect: ASPECT,
|
|
})!;
|
|
const v = buildRotateCandidate({
|
|
startAngle: 90,
|
|
center: { x: 0.5, y: 0.5 },
|
|
guards: IOS_GUARDS,
|
|
aspect: ASPECT,
|
|
})!;
|
|
// Same physical radius — only the Down placement differs.
|
|
expect(h.radiusX).toBeCloseTo(v.radiusX, 12);
|
|
expect(h.radiusY).toBeCloseTo(v.radiusY, 12);
|
|
});
|
|
|
|
it("prefers greater starting clearance, then larger orbit, among unsafe candidates", () => {
|
|
const mk = (overrides: Partial<RotateCandidate>): RotateCandidate => ({
|
|
startAngle: 0,
|
|
radiusX: 0.2,
|
|
radiusY: 0.09,
|
|
axisSafe: false,
|
|
fullyEdgeSafe: false,
|
|
clearance: -0.05,
|
|
...overrides,
|
|
});
|
|
expect(
|
|
selectRotateCandidate([mk({ clearance: -0.06 }), mk({ startAngle: 90, clearance: -0.01 })])!
|
|
.startAngle
|
|
).toBe(90);
|
|
expect(
|
|
selectRotateCandidate([mk({ radiusX: 0.1 }), mk({ startAngle: 90, radiusX: 0.3 })])!
|
|
.startAngle
|
|
).toBe(90);
|
|
});
|
|
});
|