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:
+142
-1
@@ -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, ¶ms, &uniform_names, &helper_names)?;
|
||||
let presentation = read_presentation(root, ¶m_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);
|
||||
|
||||
@@ -54,6 +54,10 @@ define: # helpers this node alone needs
|
||||
wgsl: | # `c` is linear RGB in and out
|
||||
c = c * gain;
|
||||
|
||||
presentation: # optional; omit for one control per parameter
|
||||
widgets: [tone_curve] # a hint, most preferred first
|
||||
params: [exposure] # what that widget owns
|
||||
|
||||
tests:
|
||||
- name: one_stop_is_a_doubling
|
||||
why: The definition of a stop.
|
||||
@@ -69,11 +73,32 @@ tests:
|
||||
| `stops` | exposure-like, in stops | `min`, `max` |
|
||||
| `fraction` | 0…1 | `default` |
|
||||
| `switch` | a toggle, neutral off | — |
|
||||
| `enum` | one of a short, fixed list | `variants` |
|
||||
| `scalar` | anything else | `min`, `max`, `default`, `unit`, `scale`, `precision` |
|
||||
|
||||
Reach for `amount` first. Writing its range out by hand in each node is how one
|
||||
of them comes to disagree with the rest.
|
||||
|
||||
An `enum` names its choices as localisation keys, most-neutral first:
|
||||
|
||||
```yaml
|
||||
params:
|
||||
method:
|
||||
label: param.method
|
||||
kind: enum
|
||||
variants: [param.method.fast, param.method.exact]
|
||||
```
|
||||
|
||||
The value is the chosen **index**, carried as an `f32` like every other
|
||||
parameter — so a uniform expression can use it (`method` is 0 or 1), the
|
||||
sidecar stores it unchanged, and nothing along the way needed a second kind of
|
||||
value. The default is always the first variant, so `reset` means what it means
|
||||
everywhere else; if your neutral choice is not first, reorder the list rather
|
||||
than reaching for a `default:`.
|
||||
|
||||
Two variants is the minimum. A one-entry list is a control the user cannot
|
||||
change, and the build rejects it.
|
||||
|
||||
### Uniform expressions
|
||||
|
||||
Arithmetic (`+ - * /`), parentheses, numbers, this node's parameters, and:
|
||||
@@ -91,6 +116,40 @@ default, which is the honest rule and the right one for nearly everything. A
|
||||
node whose neutral is something else declares `active:` as an expression —
|
||||
non-zero means active.
|
||||
|
||||
### Presentation
|
||||
|
||||
Optional, and absent from nearly every node: one control per parameter is the
|
||||
right answer for a list of unrelated sliders, which is what most operations
|
||||
are. Declare it when several parameters form **one** conceptual control.
|
||||
|
||||
```yaml
|
||||
presentation:
|
||||
widgets: [colour_wheel] # most preferred first
|
||||
demand:
|
||||
two_dimensional: true # dragged in x and y at once
|
||||
precise_pointing: false # needs accuracy finer than a fingertip
|
||||
params: [hue, strength] # what the widget owns, in its order
|
||||
```
|
||||
|
||||
Widgets: `tone_curve`, `colour_wheel`, `crop_overlay`, `gradient_handle`,
|
||||
`brush_mask`, `white_point`.
|
||||
|
||||
**It is a hint and only a hint.** The frontend walks `widgets` and takes the
|
||||
first it both implements and can afford; if it implements none of them, or
|
||||
cannot meet the `demand`, it renders the parameters as ordinary sliders and the
|
||||
edit still works. So it is safe to name a widget nothing draws yet — the node
|
||||
degrades to sliders rather than breaking. That fallback is also why every
|
||||
parameter must stay an ordinary scalar: it is what there is to fall back *to*.
|
||||
|
||||
`demand` says what the widget inherently needs, never what the screen has.
|
||||
There is deliberately no way to write a pixel width, a breakpoint or a platform
|
||||
name here — those are the frontend's to decide, and a node that reasoned about
|
||||
them would eventually be wrong about a display it never saw (ARCH §4.3a).
|
||||
|
||||
Parameters named in `params:` are claimed by the widget and not drawn
|
||||
separately, so a typo would take a parameter out of both. The build checks each
|
||||
name against the node's own `params:` and fails if it does not match.
|
||||
|
||||
### Tests
|
||||
|
||||
| Key | Asserts |
|
||||
|
||||
Reference in New Issue
Block a user