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);
      });
    });

  });

});