Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/bindings-roadmap.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ no further significant ReScript → AffineScript work is tractable.

|4
|*motion* (animate, animateMini, tween, ease, spring)
|`◐` scaffold (animate / await / cancel landed)
|`●` usable
|`stdlib/Motion.affine` (eventual home: `affinescript-motion`)
|idaptik `src/bindings/Motion.res`; player + UI transitions. Initial surface (`motionAnimate` / `motionAwait` / `motionCancel`) lands in `stdlib/` parallel to Http / Sqlite / Crypto; consumer provides `globalThis.__as_motion` at module-init time. Test fixture: `tests/codegen-deno/motion_smoke.{affine,harness.mjs}`. Follow-ups: `animateMini`, `tween`, `ease`, `spring`, typed keyframe shapes.
|idaptik `src/bindings/Motion.res`; player + UI transitions. Full surface (`motionAnimate` / `motionAwait` / `motionCancel` / `motionAnimateMini` / `motionTween` / `motionSpring` / `motionEase`) lives in `stdlib/` parallel to Http / Sqlite / Crypto; consumer provides `globalThis.__as_motion` at module-init time. Test fixture: `tests/codegen-deno/motion_smoke.{affine,harness.mjs}` exercises every extern. Remaining follow-ups (out of scope for `●`): typed keyframe shapes, typed transform-property surface, migration to a dedicated `affinescript-motion` package.

|5
|*WASM-exports calling pattern* — invoke individual `exports.fn_name(args)` from a `WasmExports` value
Expand Down
17 changes: 17 additions & 0 deletions lib/codegen_deno.ml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,18 @@ const __as_motionCancel = (controls) => {
if (controls && typeof controls.cancel === "function") controls.cancel();
return 0;
};
// `animateMini` / `tween` / `spring` / `ease` — bindings #4 follow-up
// surface. Each helper resolves the host method on globalThis.__as_motion
// at call time so a mock that only stubs a subset still works for the
// rest (the smoke harness exercises every variant).
const __as_motionAnimateMini = (target, keyframes, options) =>
globalThis.__as_motion.animateMini(target, keyframes, options);
const __as_motionTween = (target, from, to, options) =>
globalThis.__as_motion.tween(target, from, to, options);
const __as_motionSpring = (target, keyframes, springConfig) =>
globalThis.__as_motion.spring(target, keyframes, springConfig);
const __as_motionEase = (name) =>
globalThis.__as_motion.ease(name);
// ---- pixi.js (bindings #1): consumer-provided import ----
// Host JS environment exposes globalThis.__as_pixi (the PIXI namespace
// from `import * as PIXI from "pixi.js"`). Tests set it in the harness
Expand Down Expand Up @@ -417,6 +429,11 @@ let () =
b "pixiUiSwitchNew" (fun a -> Printf.sprintf "__as_pixiUiSwitchNew(%s)" (arg 0 a));
b "pixiUiSwitchOnChange" (fun a -> Printf.sprintf "__as_pixiUiSwitchOnChange(%s, %s)" (arg 0 a) (arg 1 a));
b "pixiUiSwitchAsContainer" (fun a -> Printf.sprintf "__as_pixiUiSwitchAsContainer(%s)" (arg 0 a));
(* ---- motion extras (bindings #4 follow-up) ---- *)
b "motionAnimateMini" (fun a -> Printf.sprintf "__as_motionAnimateMini(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
b "motionTween" (fun a -> Printf.sprintf "__as_motionTween(%s, %s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a));
b "motionSpring" (fun a -> Printf.sprintf "__as_motionSpring(%s, %s, %s)" (arg 0 a) (arg 1 a) (arg 2 a));
b "motionEase" (fun a -> Printf.sprintf "__as_motionEase(%s)" (arg 0 a));
(* Generic JS array push helper (returns the array, fluent). *)
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
(* ---- honest string/number primitives underpinning the
Expand Down
49 changes: 47 additions & 2 deletions stdlib/Motion.affine
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
// bindings roadmap is the long-term destination; the migration from
// here to there is additive and source-compatible.
//
// Surface coverage in this version: `animate` (with await + cancel).
// Follow-ups (deferred): `animateMini`, `tween`, `ease`, `spring`,
// Surface coverage in this version: `animate` (with await + cancel),
// `animateMini`, `tween`, `spring`, `ease`. Remaining follow-ups:
// keyframe-typing, transform-property typing. Status row in
// `docs/bindings-roadmap.adoc` (#4) updates with each coverage tranche.

Expand Down Expand Up @@ -56,3 +56,48 @@ pub extern fn motionAwait(controls: AnimationControls) -> Int / { Async };
/// Returns 0. A controls value without a `.cancel` method (e.g. a
/// mock) is treated as already-cancelled; this is a no-op return 0.
pub extern fn motionCancel(controls: AnimationControls) -> Int;

/// `motion.animateMini(target, keyframes, options)` — lightweight
/// variant of animate (no autoplay, no built-in promise behaviour).
/// Returns an AnimationControls handle. Used when the caller wants
/// to drive playback explicitly rather than relying on motion's
/// default autoplay + thenable semantics.
pub extern fn motionAnimateMini(
target: Json,
keyframes: Json,
options: Json
) -> AnimationControls;

/// `motion.tween(target, from, to, options)` — one-shot interpolation
/// between explicit `from` and `to` values. Distinct from `animate`
/// in that `from` is required rather than inferred from the current
/// state of `target`.
pub extern fn motionTween(
target: Json,
from: Json,
to: Json,
options: Json
) -> AnimationControls;

/// `motion.spring(target, keyframes, springConfig)` — physics-based
/// spring animation. `springConfig` is an opaque record carrying at
/// minimum `stiffness`, `damping`, and `mass`. Returns an
/// AnimationControls handle behaving identically to `animate`'s.
pub extern fn motionSpring(
target: Json,
keyframes: Json,
springConfig: Json
) -> AnimationControls;

/// Opaque easing-function value. Underlying value is whatever the
/// motion library hands back from its easing constructor — typically
/// a JS function or named token. Goes into the `ease` field of an
/// options record.
pub extern type Easing;

/// `motion.ease(name)` — construct an easing function by its canonical
/// motion-library name (`"linear"`, `"easeIn"`, `"easeOut"`,
/// `"backOut"`, etc.). The result is an opaque `Easing` value that the
/// consumer threads into an options record consumed by `motionAnimate`
/// / `motionTween` / `motionAnimateMini`.
pub extern fn motionEase(name: String) -> Easing;
19 changes: 17 additions & 2 deletions tests/codegen-deno/motion_smoke.affine
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,28 @@
//
// The harness installs a mock at globalThis.__as_motion before
// importing the generated module; we re-export thin wrappers so the
// harness can drive `motionAnimate` / `motionCancel` via stable names.
// harness can drive every motion extern via stable names.
//
// Coverage: animate / cancel (original) + animateMini / tween /
// spring / ease (bindings #4 → ● promotion).

use Deno::{Json};
use Motion::{AnimationControls, motionAnimate, motionCancel};
use Motion::{AnimationControls, Easing, motionAnimate, motionAnimateMini, motionCancel, motionEase, motionSpring, motionTween};

pub fn smokeAnimate(target: Json, keyframes: Json, options: Json) -> AnimationControls =
motionAnimate(target, keyframes, options);

pub fn smokeCancel(controls: AnimationControls) -> Int =
motionCancel(controls);

pub fn smokeAnimateMini(target: Json, keyframes: Json, options: Json) -> AnimationControls =
motionAnimateMini(target, keyframes, options);

pub fn smokeTween(target: Json, from: Json, to: Json, options: Json) -> AnimationControls =
motionTween(target, from, to, options);

pub fn smokeSpring(target: Json, keyframes: Json, springConfig: Json) -> AnimationControls =
motionSpring(target, keyframes, springConfig);

pub fn smokeEase(name: String) -> Easing =
motionEase(name);
85 changes: 74 additions & 11 deletions tests/codegen-deno/motion_smoke.harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,99 @@
// bindings #4 — Node ESM harness for the motion library binding.
//
// Installs a globalThis.__as_motion mock before importing the
// generated module, drives smokeAnimate / smokeCancel, and asserts
// the arguments + cancel side-effect were observed.
// generated module, then drives every wrapper (animate / cancel +
// animateMini / tween / spring / ease) and asserts the arguments +
// return values land as expected.

import assert from "node:assert/strict";

let lastAnimateCall = null;
let lastAnimateMiniCall = null;
let lastTweenCall = null;
let lastSpringCall = null;
let lastEaseName = null;
let cancelCount = 0;

const makeControls = (label) => ({
__label: label,
then(cb) { if (cb) cb(); return this; },
cancel() { cancelCount += 1; },
});

globalThis.__as_motion = {
animate(target, keyframes, options) {
lastAnimateCall = { target, keyframes, options };
return {
then(cb) { if (cb) cb(); return this; },
cancel() { cancelCount += 1; },
};
return makeControls("animate");
},
animateMini(target, keyframes, options) {
lastAnimateMiniCall = { target, keyframes, options };
return makeControls("animateMini");
},
tween(target, from, to, options) {
lastTweenCall = { target, from, to, options };
return makeControls("tween");
},
spring(target, keyframes, springConfig) {
lastSpringCall = { target, keyframes, springConfig };
return makeControls("spring");
},
ease(name) {
lastEaseName = name;
// Return a sentinel function so consumers can verify the value
// round-trips through an options record unchanged.
const fn = (t) => t;
fn.__easingName = name;
return fn;
},
};

const { smokeAnimate, smokeCancel } = await import("./motion_smoke.deno.js");
const {
smokeAnimate,
smokeCancel,
smokeAnimateMini,
smokeTween,
smokeSpring,
smokeEase,
} = await import("./motion_smoke.deno.js");

// ---- animate + cancel (original surface) ----
const controls = smokeAnimate("#player", { x: 100, opacity: 0.5 }, { duration: 1.0 });
assert.equal(lastAnimateCall.target, "#player", "target reaches host");
assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "keyframes reach host");
assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "options reach host");
assert.equal(lastAnimateCall.target, "#player", "animate target reaches host");
assert.deepEqual(lastAnimateCall.keyframes, { x: 100, opacity: 0.5 }, "animate keyframes reach host");
assert.deepEqual(lastAnimateCall.options, { duration: 1.0 }, "animate options reach host");

assert.equal(smokeCancel(controls), 0, "cancel returns 0");
assert.equal(cancelCount, 1, "cancel invoked exactly once");

// Cancel a null-controls value is a no-op (mock controls without .cancel)
assert.equal(smokeCancel({}), 0, "cancel on bare object returns 0");
assert.equal(cancelCount, 1, "cancel count unchanged for bare object");

// ---- animateMini ----
const miniControls = smokeAnimateMini("#hud", { y: 50 }, { duration: 0.25 });
assert.equal(lastAnimateMiniCall.target, "#hud", "animateMini target reaches host");
assert.deepEqual(lastAnimateMiniCall.keyframes, { y: 50 }, "animateMini keyframes reach host");
assert.deepEqual(lastAnimateMiniCall.options, { duration: 0.25 }, "animateMini options reach host");
assert.equal(miniControls.__label, "animateMini", "animateMini returns its controls handle");

// ---- tween ----
const tweenControls = smokeTween("#enemy", { x: 0 }, { x: 200 }, { duration: 0.6 });
assert.equal(lastTweenCall.target, "#enemy", "tween target reaches host");
assert.deepEqual(lastTweenCall.from, { x: 0 }, "tween from reaches host");
assert.deepEqual(lastTweenCall.to, { x: 200 }, "tween to reaches host");
assert.deepEqual(lastTweenCall.options, { duration: 0.6 }, "tween options reach host");
assert.equal(tweenControls.__label, "tween", "tween returns its controls handle");

// ---- spring ----
const springControls = smokeSpring("#bubble", { scale: 1.4 }, { stiffness: 240, damping: 18, mass: 1 });
assert.equal(lastSpringCall.target, "#bubble", "spring target reaches host");
assert.deepEqual(lastSpringCall.keyframes, { scale: 1.4 }, "spring keyframes reach host");
assert.deepEqual(lastSpringCall.springConfig, { stiffness: 240, damping: 18, mass: 1 }, "spring config reaches host");
assert.equal(springControls.__label, "spring", "spring returns its controls handle");

// ---- ease ----
const easing = smokeEase("backOut");
assert.equal(lastEaseName, "backOut", "ease name reaches host");
assert.equal(typeof easing, "function", "ease returns an opaque easing-function value");
assert.equal(easing.__easingName, "backOut", "easing value carries its name through the boundary");

console.log("motion_smoke.harness.mjs OK");
Loading