Skip to content

Commit d7deeeb

Browse files
feat(stdlib): @pixi/ui binding (bindings #3) (#435)
## Summary Adds an MVP `@pixi/ui` binding (`stdlib/PixiUI.affine` + Deno-ESM codegen lowerings) so idaptik's HUD + menu layer (`src/bindings/PixiUI.res`) can move off ReScript. Follows the pattern proven by wasmCall (#422) / motion (#422 bundle) / Pixi (#429 open). Row #3 in `docs/bindings-roadmap.adoc` moves `○` -> `◐` scaffold. ## Surface - 5 opaque host types: `Button`, `FancyButton`, `Slider`, `Switch`, `Container` (re-declared opaque so this module can be consumed without a hard dependency on `stdlib/Pixi.affine`). - 11 extern fns: ctor + primary event-callback registrar + zero-cost `AsContainer` upcast for each component. - 11 `__as_pixiUi*` runtime helpers + `deno_builtins` table entries in `lib/codegen_deno.ml`. - Consumer sets `globalThis.__as_pixi_ui = PixiUI` once at module-init. ## Deferred (follow-ups) - `Input` (text-entry + focus + value) - `ScrollBox` (scroll position + viewport) - Full event surface (onHover / onOut / onDown / onUp) - FancyButton textures-per-state, Slider min/max/step accessors ## Test plan - [x] `dune build bin/main.exe` clean - [x] `bash tools/run_codegen_deno_tests.sh` — 9/9 harnesses green (was 8/8; +pixiui_smoke with 17 assertions) - [x] All new files MPL-2.0 SPDX; no `.as` / AGPL / PMPL - [ ] Hypatia security scan: no new findings (delta-only — pre-existing baselines ignored per repo CLAUDE.md) Note: pre-existing flaky E2E Node-CJS Codegen tests #4/#5 unrelated. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ee9b1f2 commit d7deeeb

5 files changed

Lines changed: 281 additions & 3 deletions

File tree

docs/bindings-roadmap.adoc

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ no further significant ReScript → AffineScript work is tractable.
6161

6262
|3
6363
|*@pixi/ui* (Button, FancyButton, Switch, Slider, Input, ScrollBox)
64-
|`○`
65-
|`affinescript-pixijs` sub-module
66-
|idaptik `src/bindings/PixiUI.res`; all HUD / menu code.
64+
|`◐` scaffold
65+
|`stdlib/PixiUI.affine` (eventual home: `affinescript-pixijs` sub-module)
66+
|idaptik `src/bindings/PixiUI.res`; all HUD / menu code. MVP surface (Button / FancyButton / Slider / Switch — `new` + primary callback + `AsContainer` upcast) lives in `stdlib/` alongside Pixi / PixiSound; consumer provides `globalThis.__as_pixi_ui` at module-init time. Test fixture: `tests/codegen-deno/pixiui_smoke.{affine,harness.mjs}`. Deferred follow-ups: Input (text-entry + focus + value), ScrollBox (scroll position + viewport), full event surface (onHover / onOut / onDown / onUp), FancyButton textures-per-state accessors, Slider min/max/step accessors.
6767

6868
|4
6969
|*motion* (animate, animateMini, tween, ease, spring)

lib/codegen_deno.ml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,28 @@ const __as_pixiTextAsContainer = (t) => t;
208208
const __as_pixiTickerAdd = (t, cb) => { t.add(cb); return 0; };
209209
const __as_pixiTickerStart = (t) => { t.start(); return 0; };
210210
const __as_pixiTickerStop = (t) => { t.stop(); return 0; };
211+
// ---- @pixi/ui (bindings #3): consumer-provided import ----
212+
// Host JS environment exposes globalThis.__as_pixi_ui (the namespace
213+
// from `import * as PixiUI from "@pixi/ui"`). Tests set it in the
214+
// harness before importing the generated module; production
215+
// consumers typically do once at module-init time. The
216+
// AffineScript-side externs (stdlib/PixiUI.affine) don't see this
217+
// indirection — they call __as_pixiUi* helpers directly.
218+
//
219+
// Upcasts to Container are identity — @pixi/ui's Button /
220+
// FancyButton / Slider / Switch are all real PIXI.Container
221+
// subclasses, so the JS object is the same.
222+
const __as_pixiUiButtonNew = (options) => new globalThis.__as_pixi_ui.Button(options);
223+
const __as_pixiUiButtonOnPress = (b, cb) => { b.onPress.connect(cb); return 0; };
224+
const __as_pixiUiButtonAsContainer = (b) => b;
225+
const __as_pixiUiFancyButtonNew = (options) => new globalThis.__as_pixi_ui.FancyButton(options);
226+
const __as_pixiUiFancyButtonAsContainer = (b) => b;
227+
const __as_pixiUiSliderNew = (options) => new globalThis.__as_pixi_ui.Slider(options);
228+
const __as_pixiUiSliderOnUpdate = (s, cb) => { s.onUpdate.connect(cb); return 0; };
229+
const __as_pixiUiSliderAsContainer = (s) => s;
230+
const __as_pixiUiSwitchNew = (options) => new globalThis.__as_pixi_ui.Switch(options);
231+
const __as_pixiUiSwitchOnChange = (sw, cb) => { sw.onChange.connect(cb); return 0; };
232+
const __as_pixiUiSwitchAsContainer = (sw) => sw;
211233
// `++` is overloaded (string concat / array concat); `a + b` would
212234
// stringify arrays. Dispatch on shape so stdlib/string.affine's
213235
// `result ++ [x]` and `a ++ b` are both correct.
@@ -383,6 +405,18 @@ let () =
383405
b "pixiTickerAdd" (fun a -> Printf.sprintf "__as_pixiTickerAdd(%s, %s)" (arg 0 a) (arg 1 a));
384406
b "pixiTickerStart" (fun a -> Printf.sprintf "__as_pixiTickerStart(%s)" (arg 0 a));
385407
b "pixiTickerStop" (fun a -> Printf.sprintf "__as_pixiTickerStop(%s)" (arg 0 a));
408+
(* ---- @pixi/ui (bindings #3) ---- *)
409+
b "pixiUiButtonNew" (fun a -> Printf.sprintf "__as_pixiUiButtonNew(%s)" (arg 0 a));
410+
b "pixiUiButtonOnPress" (fun a -> Printf.sprintf "__as_pixiUiButtonOnPress(%s, %s)" (arg 0 a) (arg 1 a));
411+
b "pixiUiButtonAsContainer" (fun a -> Printf.sprintf "__as_pixiUiButtonAsContainer(%s)" (arg 0 a));
412+
b "pixiUiFancyButtonNew" (fun a -> Printf.sprintf "__as_pixiUiFancyButtonNew(%s)" (arg 0 a));
413+
b "pixiUiFancyButtonAsContainer" (fun a -> Printf.sprintf "__as_pixiUiFancyButtonAsContainer(%s)" (arg 0 a));
414+
b "pixiUiSliderNew" (fun a -> Printf.sprintf "__as_pixiUiSliderNew(%s)" (arg 0 a));
415+
b "pixiUiSliderOnUpdate" (fun a -> Printf.sprintf "__as_pixiUiSliderOnUpdate(%s, %s)" (arg 0 a) (arg 1 a));
416+
b "pixiUiSliderAsContainer" (fun a -> Printf.sprintf "__as_pixiUiSliderAsContainer(%s)" (arg 0 a));
417+
b "pixiUiSwitchNew" (fun a -> Printf.sprintf "__as_pixiUiSwitchNew(%s)" (arg 0 a));
418+
b "pixiUiSwitchOnChange" (fun a -> Printf.sprintf "__as_pixiUiSwitchOnChange(%s, %s)" (arg 0 a) (arg 1 a));
419+
b "pixiUiSwitchAsContainer" (fun a -> Printf.sprintf "__as_pixiUiSwitchAsContainer(%s)" (arg 0 a));
386420
(* Generic JS array push helper (returns the array, fluent). *)
387421
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
388422
(* ---- honest string/number primitives underpinning the

stdlib/PixiUI.affine

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// PixiUI.affine — bindings for the `@pixi/ui` v2.x npm library
5+
// (bindings #3 in docs/bindings-roadmap.adoc).
6+
//
7+
// `@pixi/ui` provides interactive UI components built on top of
8+
// PixiJS — Button, FancyButton, Switch, Slider, Input, ScrollBox.
9+
// Reference consumer: idaptik's `src/bindings/PixiUI.res`, the
10+
// HUD + menu layer over the core PixiJS scene graph.
11+
//
12+
// MVP coverage (this version): Button, FancyButton, Slider, Switch
13+
// constructors + their typical event callback + the upcast to
14+
// `Container` (so a UI component can be added to the PixiJS scene
15+
// graph via `Pixi::pixiContainerAddChild`).
16+
//
17+
// Deferred (follow-ups):
18+
// - Input — text-entry, focus, blur, value accessor
19+
// - ScrollBox — scroll position, content add/remove, viewport
20+
// - Full event surface (onHover / onOut / onDown / onUp)
21+
// - FancyButton style accessors (textures per state)
22+
// - Slider min/max/step accessors
23+
//
24+
// Targets the Deno-ESM backend. Consumer (or its host wrapper) sets
25+
// `globalThis.__as_pixi_ui` to the `@pixi/ui` namespace at module-
26+
// init time:
27+
//
28+
// import * as PixiUI from "@pixi/ui";
29+
// globalThis.__as_pixi_ui = PixiUI;
30+
//
31+
// Tests mock the same shape — see `tests/codegen-deno/pixiui_smoke.*`.
32+
//
33+
// @pixi/ui type hierarchy: Button, FancyButton, Slider, Switch are
34+
// all `Container` subclasses (via PixiJS). AffineScript has no
35+
// subtype polymorphism, so the binding exposes explicit upcast
36+
// functions (`pixiUiButtonAsContainer`, etc.). These are zero-cost
37+
// identity lowerings; the underlying JS value is the same object.
38+
// The `Container` type is re-declared as an opaque extern here for
39+
// parity with `stdlib/Pixi.affine` — the JS-side handle for the two
40+
// is identical (a PIXI.Container instance).
41+
42+
module PixiUI;
43+
44+
use Deno::{Json};
45+
46+
// ── Opaque host types ──────────────────────────────────────────────
47+
//
48+
// Each maps to its same-named `@pixi/ui` class. They're treated
49+
// opaquely at the AS boundary; the only consumer-visible operations
50+
// are construction, callback registration, and upcast to Container.
51+
52+
pub extern type Button;
53+
pub extern type FancyButton;
54+
pub extern type Slider;
55+
pub extern type Switch;
56+
57+
/// `PIXI.Container` — re-declared as opaque extern so this module can
58+
/// be consumed without a hard dependency on `stdlib/Pixi.affine`.
59+
/// When both modules are imported, the underlying JS values coincide
60+
/// (both resolve to the same `PIXI.Container` host class), so a
61+
/// container produced here can be added as a child of an
62+
/// `Application.stage` from `Pixi.affine` with no further marshalling.
63+
pub extern type Container;
64+
65+
// ── Button ─────────────────────────────────────────────────────────
66+
67+
/// `new PixiUI.Button(options)`.
68+
/// `options` is the @pixi/ui Button-options JSON (typically `{ view }`
69+
/// where `view` is a PIXI display object).
70+
pub extern fn pixiUiButtonNew(options: Json) -> Button;
71+
72+
/// `button.onPress.connect(callback)` — register a press-event
73+
/// handler. `callback` crosses the boundary as opaque `Json` (the
74+
/// host-side JS function value). Returns 0.
75+
pub extern fn pixiUiButtonOnPress(b: Button, callback: Json) -> Int;
76+
77+
/// Upcast a Button to its Container superclass. Zero-cost identity
78+
/// lowering — the underlying JS value is the same object.
79+
pub extern fn pixiUiButtonAsContainer(b: Button) -> Container;
80+
81+
// ── FancyButton ────────────────────────────────────────────────────
82+
83+
/// `new PixiUI.FancyButton(options)`. `options` is the @pixi/ui
84+
/// FancyButton-options JSON (text + textures-per-state + padding).
85+
pub extern fn pixiUiFancyButtonNew(options: Json) -> FancyButton;
86+
87+
/// Upcast a FancyButton to its Container superclass. Zero-cost
88+
/// identity lowering.
89+
pub extern fn pixiUiFancyButtonAsContainer(b: FancyButton) -> Container;
90+
91+
// ── Slider ─────────────────────────────────────────────────────────
92+
93+
/// `new PixiUI.Slider(options)`. `options` is the @pixi/ui
94+
/// Slider-options JSON (min/max/value/bg/fill/slider textures).
95+
pub extern fn pixiUiSliderNew(options: Json) -> Slider;
96+
97+
/// `slider.onUpdate.connect(callback)` — register a value-change
98+
/// handler. Callback receives the new value (number). Returns 0.
99+
pub extern fn pixiUiSliderOnUpdate(s: Slider, callback: Json) -> Int;
100+
101+
/// Upcast a Slider to its Container superclass. Zero-cost identity
102+
/// lowering.
103+
pub extern fn pixiUiSliderAsContainer(s: Slider) -> Container;
104+
105+
// ── Switch ─────────────────────────────────────────────────────────
106+
107+
/// `new PixiUI.Switch(options)`. `options` is the @pixi/ui
108+
/// Switch-options JSON (textures + value).
109+
pub extern fn pixiUiSwitchNew(options: Json) -> Switch;
110+
111+
/// `switch.onChange.connect(callback)` — register a state-change
112+
/// handler. Returns 0.
113+
pub extern fn pixiUiSwitchOnChange(sw: Switch, callback: Json) -> Int;
114+
115+
/// Upcast a Switch to its Container superclass. Zero-cost identity
116+
/// lowering.
117+
pub extern fn pixiUiSwitchAsContainer(sw: Switch) -> Container;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// bindings #3 — @pixi/ui smoke test.
3+
//
4+
// The harness mocks globalThis.__as_pixi_ui before importing the
5+
// generated module; this fixture exercises construction, callback
6+
// registration, and upcast → Container for each MVP component.
7+
8+
use Deno::{Json};
9+
use PixiUI::{Button, FancyButton, Slider, Switch, Container, pixiUiButtonNew, pixiUiButtonOnPress, pixiUiButtonAsContainer, pixiUiFancyButtonNew, pixiUiFancyButtonAsContainer, pixiUiSliderNew, pixiUiSliderOnUpdate, pixiUiSliderAsContainer, pixiUiSwitchNew, pixiUiSwitchOnChange, pixiUiSwitchAsContainer};
10+
11+
pub fn smokeButton(options: Json, callback: Json) -> Container {
12+
let b = pixiUiButtonNew(options);
13+
pixiUiButtonOnPress(b, callback);
14+
pixiUiButtonAsContainer(b)
15+
}
16+
17+
pub fn smokeFancyButton(options: Json) -> Container {
18+
let fb = pixiUiFancyButtonNew(options);
19+
pixiUiFancyButtonAsContainer(fb)
20+
}
21+
22+
pub fn smokeSlider(options: Json, callback: Json) -> Container {
23+
let s = pixiUiSliderNew(options);
24+
pixiUiSliderOnUpdate(s, callback);
25+
pixiUiSliderAsContainer(s)
26+
}
27+
28+
pub fn smokeSwitch(options: Json, callback: Json) -> Container {
29+
let sw = pixiUiSwitchNew(options);
30+
pixiUiSwitchOnChange(sw, callback);
31+
pixiUiSwitchAsContainer(sw)
32+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// bindings #3 — Node ESM harness for the @pixi/ui binding.
3+
//
4+
// Installs a globalThis.__as_pixi_ui mock covering Button /
5+
// FancyButton / Slider / Switch, drives the smoke functions, and
6+
// asserts every constructor + callback registration + upcast was
7+
// observed. Upcasts are identity at the JS level (the @pixi/ui
8+
// classes are PIXI.Container subclasses); the harness mirrors that.
9+
10+
import assert from "node:assert/strict";
11+
12+
const ctorCalls = { Button: [], FancyButton: [], Slider: [], Switch: [] };
13+
const onPressRegs = [];
14+
const onUpdateRegs = [];
15+
const onChangeRegs = [];
16+
17+
// `Signal`-ish stub matching the @pixi/ui event surface
18+
// (`button.onPress.connect(cb)`). Records every registered callback
19+
// into the sink array passed in at construction time.
20+
class MockSignal {
21+
constructor(sink) { this._sink = sink; }
22+
connect(cb) { this._sink.push(cb); }
23+
}
24+
25+
class MockButton {
26+
constructor(options) {
27+
ctorCalls.Button.push(options);
28+
this.onPress = new MockSignal(onPressRegs);
29+
}
30+
}
31+
32+
class MockFancyButton {
33+
constructor(options) {
34+
ctorCalls.FancyButton.push(options);
35+
}
36+
}
37+
38+
class MockSlider {
39+
constructor(options) {
40+
ctorCalls.Slider.push(options);
41+
this.onUpdate = new MockSignal(onUpdateRegs);
42+
}
43+
}
44+
45+
class MockSwitch {
46+
constructor(options) {
47+
ctorCalls.Switch.push(options);
48+
this.onChange = new MockSignal(onChangeRegs);
49+
}
50+
}
51+
52+
globalThis.__as_pixi_ui = {
53+
Button: MockButton,
54+
FancyButton: MockFancyButton,
55+
Slider: MockSlider,
56+
Switch: MockSwitch,
57+
};
58+
59+
const { smokeButton, smokeFancyButton, smokeSlider, smokeSwitch } =
60+
await import("./pixiui_smoke.deno.js");
61+
62+
// ── Button: ctor + onPress + upcast ────────────────────────────────
63+
const buttonPressCb = () => "pressed";
64+
const buttonContainer = smokeButton({ text: "OK" }, buttonPressCb);
65+
assert.equal(ctorCalls.Button.length, 1, "Button ctor called once");
66+
assert.deepEqual(ctorCalls.Button[0], { text: "OK" }, "Button options reach host");
67+
assert.equal(onPressRegs.length, 1, "onPress.connect called once");
68+
assert.equal(onPressRegs[0], buttonPressCb, "onPress callback identity preserved");
69+
assert.ok(buttonContainer instanceof MockButton, "Button upcast is identity (still MockButton)");
70+
71+
// ── FancyButton: ctor + upcast ─────────────────────────────────────
72+
const fancyContainer = smokeFancyButton({ text: "Start", padding: 8 });
73+
assert.equal(ctorCalls.FancyButton.length, 1, "FancyButton ctor called once");
74+
assert.deepEqual(ctorCalls.FancyButton[0], { text: "Start", padding: 8 }, "FancyButton options reach host");
75+
assert.ok(fancyContainer instanceof MockFancyButton, "FancyButton upcast is identity");
76+
77+
// ── Slider: ctor + onUpdate + upcast ───────────────────────────────
78+
const sliderUpdateCb = (v) => v * 2;
79+
const sliderContainer = smokeSlider({ min: 0, max: 100, value: 50 }, sliderUpdateCb);
80+
assert.equal(ctorCalls.Slider.length, 1, "Slider ctor called once");
81+
assert.deepEqual(ctorCalls.Slider[0], { min: 0, max: 100, value: 50 }, "Slider options reach host");
82+
assert.equal(onUpdateRegs.length, 1, "onUpdate.connect called once");
83+
assert.equal(onUpdateRegs[0], sliderUpdateCb, "onUpdate callback identity preserved");
84+
assert.ok(sliderContainer instanceof MockSlider, "Slider upcast is identity");
85+
86+
// ── Switch: ctor + onChange + upcast ───────────────────────────────
87+
const switchChangeCb = (state) => !state;
88+
const switchContainer = smokeSwitch({ value: false }, switchChangeCb);
89+
assert.equal(ctorCalls.Switch.length, 1, "Switch ctor called once");
90+
assert.deepEqual(ctorCalls.Switch[0], { value: false }, "Switch options reach host");
91+
assert.equal(onChangeRegs.length, 1, "onChange.connect called once");
92+
assert.equal(onChangeRegs[0], switchChangeCb, "onChange callback identity preserved");
93+
assert.ok(switchContainer instanceof MockSwitch, "Switch upcast is identity");
94+
95+
console.log("pixiui_smoke.harness.mjs OK");

0 commit comments

Comments
 (0)