Input.js
The complete source of src/Input.js, exactly as it ships, in reading order. Every line is present; the file is split at its own section banners.
Input.js - the editor's input controls (one flat tier, no labels)
// ============================================================================
// Input.js - the editor's input controls (one flat tier, no labels)
// ============================================================================
//
// The editor's control leaves. Each control is two-way bound to a single settable node
// (its read source AND write sink, doc 12 sec 6) - no OnChange relay. An input is JUST
// the control; labelling is Content.Label / Content.Title. Every function declares its
// inputs with Reactive.Argument.
//
// Input.Text(value) / Number(value) / ReadOnly(value) / Checkbox(value)
// Input.Slider.<Type>(value, optionalMin, optionalMax) value within [min,max]
// Input.Range.<Type>(minimum, maximum) edit the [min,max] bounds
// Input.Dropdown(selected, options) + Dropdown.Option(value, label)
// Input.Radio(selected, options) + Radio.Option(value, label)
// Input.Code.Glsl(source)
// <Type> = Float / Int / Vector2 / Vector3 / Vector4
//
// Two mechanics make these work over the Dom driver:
// - DISPLAY uses Dom.Property (el.value / el.checked), not an attribute, so a
// controlled control keeps reflecting the bound value after the user types.
// - NUMERIC EDITS go through a Reactive.Reverse "entry" sink: the DOM hands a string,
// the reverse parses it and Sets the typed (Maths.Real / Maths.Integer) binding. A
// raw string can never reach a numeric binding, so strict typing is satisfied and no
// new event kind is needed. Text edits bind straight to the settable (string in,
// string out). Checkboxes/radios use Dom.Event.Checked / Dom.Event.Set (native value,
// no coercion).
//
// A Slider renders its range track only when BOTH bounds are given (a caller passes a
// literal null for an unbounded axis, e.g. a marker position); the numeric readout is
// always shown, so an unbounded value is still editable by typing. Vector variants
// compose one scalar Slider/Range per component by dotting the reversible proxy.
//
// Dropdown/Radio define Option INSIDE their body so it captures `selected` by closure
// (the settled pattern, replacing ambient context).
// ----------------------------------------------------------------------------
Reactive.Module("Input", () => {
// ---- private helpers ------------------------------------------------------
// Base-10 integer parse (parseInt's radix argument is not optional in this codebase).
function parseIntBase10(text) {
return parseInt(text, 10);
}
// A reverse "entry" sink for a numeric binding: on a Set (from the DOM event) it parses
// the incoming string and, when it is a real number, writes the typed value. A blank or
// half-typed value (NaN) is ignored, leaving the binding at its last good value.
function numberEntry(value, parse) {
return Reactive.Reverse((text) => {
const parsed = parse(text);
if (!Number.isNaN(parsed)) {
Reactive.Set(value, parsed);
}
});
}
// A single-line numeric box bound to `value`, parsing with `parse` on input. Shared by
// Input.Number, the Slider readouts, and the Range bound boxes so each parses correctly.
function numberInput(value, parse) {
const entry = numberEntry(value, parse);
Dom.Tag("input", () => {
Dom.Attribute("type", "text");
Dom.Property("value", value);
Dom.Event.Value("input", entry);
});
}
// A display value for a possibly-null Text binding: null renders as the empty string, so
// an unset optional slot (a code block with no code) shows blank instead of "null". The
// null-tolerance lives here, in the shared leaf, so callers pass the raw slot binding.
function textDisplay(value) {
return Reactive.Calculate(() => {
const raw = Reactive.Value(value);
return raw == null ? "" : raw;
});
}
// ---- text / number / boolean ----------------------------------------------
// Text - a single-line string box. Two-way binds `value` (string in, string out). Renders
// empty for a null optional slot and materialises the value on first edit. Renders: <input>.
Reactive.Function("Text", () => {
const value = Reactive.Argument(Text);
Dom.Tag("input", () => {
Dom.Attribute("type", "text");
Dom.Property("value", textDisplay(value));
Dom.Event.Value("input", value);
});
});
// Number - a numeric box. Two-way binds one Real node via a parsing entry. Renders: <input>.
Reactive.Function("Number", () => {
const value = Reactive.Argument(Maths.Real);
numberInput(value, parseFloat);
});
// ReadOnly - a bound value shown as static text. Renders: <span>value</span>.
Reactive.Function("ReadOnly", () => {
const value = Reactive.Argument(Reactive.Any);
Content.Text(value);
});
// Checkbox - a boolean toggle. Binds `checked` and writes the checkbox's checked state
// back on change. Renders: <input type="checkbox">.
Reactive.Function("Checkbox", () => {
const value = Reactive.Argument(Reactive.Any);
Dom.Tag("input", () => {
Dom.Attribute("type", "checkbox");
Dom.Property("checked", value);
Dom.Event.Checked("change", value);
});
});
// ---- sliders --------------------------------------------------------------
// Slider - a value moved within an optional [min,max]: a range track (only when bounded)
// plus a numeric readout, both bound to the same `value`. The vector variants render one
// scalar slider per component by dotting the reversible proxy.
Reactive.Module("Slider", () => {
Reactive.Function("Float", () => {
const value = Reactive.Argument(Maths.Real);
const optionalMin = Reactive.Argument(Maths.Real);
const optionalMax = Reactive.Argument(Maths.Real);
const bounded = optionalMin != null && optionalMax != null;
Reactive.If(bounded, () => {
const entry = numberEntry(value, parseFloat);
Dom.Tag("input", () => {
Dom.Attribute("type", "range");
Dom.Attribute("min", optionalMin);
Dom.Attribute("max", optionalMax);
Dom.Attribute("step", "any");
Dom.Property("value", value);
Dom.Event.Value("input", entry);
});
});
numberInput(value, parseFloat);
});
Reactive.Function("Int", () => {
const value = Reactive.Argument(Maths.Integer);
const optionalMin = Reactive.Argument(Maths.Integer);
const optionalMax = Reactive.Argument(Maths.Integer);
const bounded = optionalMin != null && optionalMax != null;
Reactive.If(bounded, () => {
const entry = numberEntry(value, parseIntBase10);
Dom.Tag("input", () => {
Dom.Attribute("type", "range");
Dom.Attribute("min", optionalMin);
Dom.Attribute("max", optionalMax);
Dom.Attribute("step", "1");
Dom.Property("value", value);
Dom.Event.Value("input", entry);
});
});
numberInput(value, parseIntBase10);
});
Reactive.Function("Vector2", () => {
const value = Reactive.Argument(Maths.Vector2);
const optionalMin = Reactive.Argument(Maths.Vector2);
const optionalMax = Reactive.Argument(Maths.Vector2);
const minX = optionalMin == null ? null : optionalMin.x;
const minY = optionalMin == null ? null : optionalMin.y;
const maxX = optionalMax == null ? null : optionalMax.x;
const maxY = optionalMax == null ? null : optionalMax.y;
Input.Slider.Float(value.x, minX, maxX);
Input.Slider.Float(value.y, minY, maxY);
});
Reactive.Function("Vector3", () => {
const value = Reactive.Argument(Maths.Vector3);
const optionalMin = Reactive.Argument(Maths.Vector3);
const optionalMax = Reactive.Argument(Maths.Vector3);
const minX = optionalMin == null ? null : optionalMin.x;
const minY = optionalMin == null ? null : optionalMin.y;
const minZ = optionalMin == null ? null : optionalMin.z;
const maxX = optionalMax == null ? null : optionalMax.x;
const maxY = optionalMax == null ? null : optionalMax.y;
const maxZ = optionalMax == null ? null : optionalMax.z;
Input.Slider.Float(value.x, minX, maxX);
Input.Slider.Float(value.y, minY, maxY);
Input.Slider.Float(value.z, minZ, maxZ);
});
Reactive.Function("Vector4", () => {
const value = Reactive.Argument(Maths.Vector4);
const optionalMin = Reactive.Argument(Maths.Vector4);
const optionalMax = Reactive.Argument(Maths.Vector4);
const minX = optionalMin == null ? null : optionalMin.x;
const minY = optionalMin == null ? null : optionalMin.y;
const minZ = optionalMin == null ? null : optionalMin.z;
const minW = optionalMin == null ? null : optionalMin.w;
const maxX = optionalMax == null ? null : optionalMax.x;
const maxY = optionalMax == null ? null : optionalMax.y;
const maxZ = optionalMax == null ? null : optionalMax.z;
const maxW = optionalMax == null ? null : optionalMax.w;
Input.Slider.Float(value.x, minX, maxX);
Input.Slider.Float(value.y, minY, maxY);
Input.Slider.Float(value.z, minZ, maxZ);
Input.Slider.Float(value.w, minW, maxW);
});
});
// ---- ranges ---------------------------------------------------------------
// Range - edits the [minimum, maximum] bounds THEMSELVES (a pair of number boxes), the
// complement of Slider. Sharing the min/max nodes with a Slider keeps the two in sync.
Reactive.Module("Range", () => {
Reactive.Function("Float", () => {
const minimum = Reactive.Argument(Maths.Real);
const maximum = Reactive.Argument(Maths.Real);
numberInput(minimum, parseFloat);
numberInput(maximum, parseFloat);
});
Reactive.Function("Int", () => {
const minimum = Reactive.Argument(Maths.Integer);
const maximum = Reactive.Argument(Maths.Integer);
numberInput(minimum, parseIntBase10);
numberInput(maximum, parseIntBase10);
});
Reactive.Function("Vector2", () => {
const minimum = Reactive.Argument(Maths.Vector2);
const maximum = Reactive.Argument(Maths.Vector2);
Input.Range.Float(minimum.x, maximum.x);
Input.Range.Float(minimum.y, maximum.y);
});
Reactive.Function("Vector3", () => {
const minimum = Reactive.Argument(Maths.Vector3);
const maximum = Reactive.Argument(Maths.Vector3);
Input.Range.Float(minimum.x, maximum.x);
Input.Range.Float(minimum.y, maximum.y);
Input.Range.Float(minimum.z, maximum.z);
});
Reactive.Function("Vector4", () => {
const minimum = Reactive.Argument(Maths.Vector4);
const maximum = Reactive.Argument(Maths.Vector4);
Input.Range.Float(minimum.x, maximum.x);
Input.Range.Float(minimum.y, maximum.y);
Input.Range.Float(minimum.z, maximum.z);
Input.Range.Float(minimum.w, maximum.w);
});
});
// ---- selection (dropdown / radio) -----------------------------------------
// Dropdown - bound to `selected`; `options` declares the choices with Dropdown.Option
// (defined inside, capturing `selected`). Renders: <select>.
Reactive.Function("Dropdown", () => {
const selected = Reactive.Argument(Reactive.Any);
const options = Reactive.Argument(Reactive.Callback());
// Option - one choice; `label` is its visible text; marks itself selected while
// selected == value. Renders: <option value>label</option>.
Reactive.Function("Option", () => {
const value = Reactive.Argument(Reactive.Any);
const label = Reactive.Argument(Text);
const active = Reactive.Calculate(() => {
return Reactive.Value(selected) === Reactive.Value(value);
});
Dom.Tag("option", () => {
Dom.Attribute("value", value);
Dom.Property("selected", active);
Content.Text(label);
});
});
// The select writes the chosen option's string value back into `selected` on change.
// (A select whose `selected` is a non-string enum needs a coercing entry; add when a
// consumer needs it - today the enum pickers use Input.Radio, whose Option values keep
// their native type through Dom.Event.Set.)
Dom.Tag("select", () => {
Dom.Event.Value("change", selected);
options();
});
});
// Radio - a 1-of-N radio group bound to `selected`; same contract as Dropdown, drawn as
// radios. Option is inner so it captures `selected`; its click writes the option's NATIVE
// value (int or string, no coercion) via Dom.Event.Set. Renders a group of
// <label><input type="radio"> label</label>.
Reactive.Function("Radio", () => {
const selected = Reactive.Argument(Reactive.Any);
const options = Reactive.Argument(Reactive.Callback());
// A shared group name so the browser treats the radios as one exclusive set.
const groupName = Reactive.StableKey("radio-");
Reactive.Function("Option", () => {
const value = Reactive.Argument(Reactive.Any);
const label = Reactive.Argument(Text);
const active = Reactive.Calculate(() => {
return Reactive.Value(selected) === Reactive.Value(value);
});
Dom.Tag("label", () => {
Dom.Tag("input", () => {
Dom.Attribute("type", "radio");
Dom.Attribute("name", groupName);
Dom.Property("checked", active);
Dom.Event.Set("change", selected, value);
});
Content.Text(label);
});
});
Dom.Tag("div", () => {
Dom.Attribute("class", "radio-group");
options();
});
});
// ---- code -----------------------------------------------------------------
// Code - code editors, one leaf per language; each two-way binds its `source` string.
Reactive.Module("Code", () => {
// Glsl - a GLSL code editor. First cut: a <textarea> two-way bound to `source` (renders
// empty for a null slot and materialises on first edit). A full Ace editor is a later
// swap via a custom driver that owns its own DOM + undo stack (like the WebGL canvas).
// Renders: <textarea class="code-editor">.
Reactive.Function("Glsl", () => {
const source = Reactive.Argument(Text);
Dom.Tag("textarea", () => {
Dom.Attribute("class", "code-editor");
Dom.Attribute("spellcheck", "false");
Dom.Property("value", textDisplay(source));
Dom.Event.Value("input", source);
});
});
});
});