Let a declared node ask for a widget, and write down how

Two gaps in the control vocabulary, both found by reading back what 0a331c7
actually shipped against what it claimed.

**`kind: enum` worked and was undocumented.** The whole point of that kind is
that a node author can declare a control without touching the interface, and
ops/README.md is where a node author looks. A kind absent from the table is one
nobody will use. It is now in the table, with the property that makes it cheap
spelled out: the value is the chosen index carried as an `f32` like every other
parameter, so a uniform expression can read it, the sidecar stores it
unchanged, and nothing along the way needed a second kind of value.

**A declared node could not ask for a widget at all.** `Presentation` gained an
ordered preference list and a demand, and `tone_curve` and `framing` use them —
but both are hand-written Rust. A YAML node had no way to say that several of
its parameters form one control, so the generated half of the pipeline was
locked out of the half of FR-DEV-3a that makes widgets extensible. Nodes now
take a `presentation:` block:

    presentation:
      widgets: [colour_wheel]
      demand: { two_dimensional: true }
      params: [hue, strength]

`demand` carries exactly two flags and there is deliberately no key for a pixel
width, a breakpoint or a platform name — those are the frontend's to decide,
and ARCH §4.3a is explicit that a core reasoning about them will eventually be
wrong about a display it never saw.

Widget names are spelled in the YAML the way `WidgetKind` spells them, so a
declaration and descriptor.rs cannot drift into two vocabularies for one idea,
and an unknown one is a build error listing the six that exist. `params:` is
checked against the node's own parameters: the widget claims what it names and
the generic path skips what was claimed, so a typo would silently drop a
parameter out of *both* — the one mistake here that produces no error and no
control.

Verified by declaring a presentation on a node, confirming the generated
`fn presentation` matched, and confirming both error paths report the file and
the key; then reverted, since no node in the chain wants a widget yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 23:54:10 +02:00
co-authored by Claude Opus 5
parent 5e4b8de18d
commit 23f0c4b76a
2 changed files with 201 additions and 1 deletions
+142 -1
View File
@@ -109,6 +109,16 @@ struct UniformDef {
rust: String,
}
/// A node's `presentation:` block, ready to render as a `Presentation`.
struct PresentationDef {
/// Rendered `WidgetKind` paths, most preferred first.
widgets: Vec<String>,
/// The owned parameters' generated `ParamId` constants, in widget order.
params: Vec<String>,
two_dimensional: bool,
precise_pointing: bool,
}
/// One generated `#[test]`.
struct TestDef {
name: String,
@@ -139,6 +149,11 @@ enum Node {
/// `None` means the default rule: active when any parameter has moved.
active: Option<String>,
tests: Vec<TestDef>,
/// `None` — the usual case — means one control per parameter.
///
/// Boxed so the rare node that declares one does not widen every
/// `Node` value by the size of a presentation it does not have.
presentation: Option<Box<PresentationDef>>,
},
Rust {
id: String,
@@ -414,6 +429,7 @@ fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result<Node, String> {
.chain(local_helpers.iter().map(|h| h.name.as_str()))
.collect();
let tests = read_tests(root, &params, &uniform_names, &helper_names)?;
let presentation = read_presentation(root, &param_names)?;
Ok(Node::Declared {
id,
@@ -428,9 +444,109 @@ fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result<Node, String> {
wgsl,
active,
tests,
presentation,
})
}
/// The widgets a node would like, in descending order of preference.
///
/// Optional, and absent on nearly every node — one control per parameter is
/// the right answer for a list of unrelated sliders, which is what most
/// operations are. Declaring this says that several parameters form *one*
/// conceptual control.
///
/// It is a hint and nothing more (ARCH §4.3a). A frontend implementing none of
/// the named widgets renders the parameters as ordinary sliders and the edit
/// still works, which is why the list can safely name widgets that do not
/// exist yet.
fn read_presentation(
root: &Mapping,
params: &BTreeSet<&str>,
) -> Result<Option<Box<PresentationDef>>, String> {
let Some(value) = root.get("presentation") else {
return Ok(None);
};
let m = as_mapping(value, "presentation")?;
let widgets = m
.get("widgets")
.and_then(Value::as_sequence)
.ok_or("`presentation` needs a `widgets:` list, most preferred first")?;
if widgets.is_empty() {
return Err("`presentation.widgets` is empty; omit `presentation:` instead".into());
}
let widgets = widgets
.iter()
.enumerate()
.map(|(i, w)| {
let name = as_str(w, &format!("presentation.widgets[{i}]"))?;
// Spelled in the YAML the way the enum spells it, so a node
// declaration and `descriptor.rs` cannot drift into two
// vocabularies for one idea.
match name {
"tone_curve" => Ok("WidgetKind::ToneCurve"),
"colour_wheel" => Ok("WidgetKind::ColourWheel"),
"crop_overlay" => Ok("WidgetKind::CropOverlay"),
"gradient_handle" => Ok("WidgetKind::GradientHandle"),
"brush_mask" => Ok("WidgetKind::BrushMask"),
"white_point" => Ok("WidgetKind::WhitePoint"),
other => Err(format!(
"`presentation.widgets[{i}]` is `{other}`; expected tone_curve, \
colour_wheel, crop_overlay, gradient_handle, brush_mask or white_point"
)),
}
})
.collect::<Result<Vec<_>, _>>()?;
// The parameters the widget owns, in the order it expects them. Checked
// against the node's own list, because a typo here would silently leave a
// parameter out of the widget *and* out of the panel — the widget claims
// it, and the generic path skips what the widget claimed.
let owned = m
.get("params")
.and_then(Value::as_sequence)
.ok_or("`presentation` needs a `params:` list naming what the widget owns")?;
let owned = owned
.iter()
.enumerate()
.map(|(i, p)| {
let name = as_str(p, &format!("presentation.params[{i}]"))?;
if !params.contains(name) {
return Err(format!(
"`presentation.params[{i}]` is `{name}`, which this node does not declare"
));
}
Ok(name.to_uppercase())
})
.collect::<Result<Vec<_>, _>>()?;
let demand = match m.get("demand") {
None => (false, false),
Some(d) => {
let d = as_mapping(d, "presentation.demand")?;
let flag = |key: &str| -> Result<bool, String> {
match d.get(key) {
None => Ok(false),
Some(v) => v.as_bool().ok_or_else(|| {
format!("`presentation.demand.{key}` must be true or false")
}),
}
};
// Deliberately only these two. ARCH §4.3a forbids a demand
// carrying pixels, breakpoints or a platform name — those are the
// frontend's to decide — so there is no key here to write one in.
(flag("two_dimensional")?, flag("precise_pointing")?)
}
};
Ok(Some(Box::new(PresentationDef {
widgets: widgets.into_iter().map(str::to_string).collect(),
params: owned,
two_dimensional: demand.0,
precise_pointing: demand.1,
})))
}
fn read_params(root: &Mapping) -> Result<Vec<ParamDef>, String> {
let params = root
.get("params")
@@ -1296,6 +1412,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
wgsl,
active,
tests,
presentation,
..
} = node
else {
@@ -1317,7 +1434,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
out.push_str(
" #[allow(unused_imports)]\n\
\x20 use crate::descriptor::{\n\
\x20 LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,\n\
\x20 LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Presentation,\n\
\x20 Scale, Unit, WidgetDemand, WidgetKind,\n\
\x20 };\n\
\x20 #[allow(unused_imports)]\n\
\x20 use crate::operation::{Helper, Operation, Uniform};\n\n",
@@ -1462,6 +1580,29 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
"\n fn helpers(&self) -> &'static [Helper] {\n HELPERS\n }\n",
);
}
// A node that asked for a widget. Omitted entirely otherwise, so the
// trait's default — one control per parameter — stands.
if let Some(p) = presentation {
let _ = writeln!(
out,
"\n fn presentation(&self) -> Option<Presentation> {{\n\
\x20 Some(Presentation {{\n\
\x20 widgets: &[{}],\n\
\x20 demand: WidgetDemand {{\n\
\x20 two_dimensional: {},\n\
\x20 precise_pointing: {},\n\
\x20 }},\n\
\x20 params: &[{}],\n\
\x20 }})\n\
\x20 }}",
p.widgets.join(", "),
p.two_dimensional,
p.precise_pointing,
p.params.join(", ")
);
}
out.push_str(" }\n");
emit_tests(out, &ty, params, tests);