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,
|
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]`.
|
/// One generated `#[test]`.
|
||||||
struct TestDef {
|
struct TestDef {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -139,6 +149,11 @@ enum Node {
|
|||||||
/// `None` means the default rule: active when any parameter has moved.
|
/// `None` means the default rule: active when any parameter has moved.
|
||||||
active: Option<String>,
|
active: Option<String>,
|
||||||
tests: Vec<TestDef>,
|
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 {
|
Rust {
|
||||||
id: String,
|
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()))
|
.chain(local_helpers.iter().map(|h| h.name.as_str()))
|
||||||
.collect();
|
.collect();
|
||||||
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
let tests = read_tests(root, ¶ms, &uniform_names, &helper_names)?;
|
||||||
|
let presentation = read_presentation(root, ¶m_names)?;
|
||||||
|
|
||||||
Ok(Node::Declared {
|
Ok(Node::Declared {
|
||||||
id,
|
id,
|
||||||
@@ -428,9 +444,109 @@ fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result<Node, String> {
|
|||||||
wgsl,
|
wgsl,
|
||||||
active,
|
active,
|
||||||
tests,
|
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> {
|
fn read_params(root: &Mapping) -> Result<Vec<ParamDef>, String> {
|
||||||
let params = root
|
let params = root
|
||||||
.get("params")
|
.get("params")
|
||||||
@@ -1296,6 +1412,7 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
|
|||||||
wgsl,
|
wgsl,
|
||||||
active,
|
active,
|
||||||
tests,
|
tests,
|
||||||
|
presentation,
|
||||||
..
|
..
|
||||||
} = node
|
} = node
|
||||||
else {
|
else {
|
||||||
@@ -1317,7 +1434,8 @@ fn emit_node(out: &mut String, node: &Node) -> Result<(), String> {
|
|||||||
out.push_str(
|
out.push_str(
|
||||||
" #[allow(unused_imports)]\n\
|
" #[allow(unused_imports)]\n\
|
||||||
\x20 use crate::descriptor::{\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 };\n\
|
||||||
\x20 #[allow(unused_imports)]\n\
|
\x20 #[allow(unused_imports)]\n\
|
||||||
\x20 use crate::operation::{Helper, Operation, Uniform};\n\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",
|
"\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");
|
out.push_str(" }\n");
|
||||||
|
|
||||||
emit_tests(out, &ty, params, tests);
|
emit_tests(out, &ty, params, tests);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ define: # helpers this node alone needs
|
|||||||
wgsl: | # `c` is linear RGB in and out
|
wgsl: | # `c` is linear RGB in and out
|
||||||
c = c * gain;
|
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:
|
tests:
|
||||||
- name: one_stop_is_a_doubling
|
- name: one_stop_is_a_doubling
|
||||||
why: The definition of a stop.
|
why: The definition of a stop.
|
||||||
@@ -69,11 +73,32 @@ tests:
|
|||||||
| `stops` | exposure-like, in stops | `min`, `max` |
|
| `stops` | exposure-like, in stops | `min`, `max` |
|
||||||
| `fraction` | 0…1 | `default` |
|
| `fraction` | 0…1 | `default` |
|
||||||
| `switch` | a toggle, neutral off | — |
|
| `switch` | a toggle, neutral off | — |
|
||||||
|
| `enum` | one of a short, fixed list | `variants` |
|
||||||
| `scalar` | anything else | `min`, `max`, `default`, `unit`, `scale`, `precision` |
|
| `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
|
Reach for `amount` first. Writing its range out by hand in each node is how one
|
||||||
of them comes to disagree with the rest.
|
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
|
### Uniform expressions
|
||||||
|
|
||||||
Arithmetic (`+ - * /`), parentheses, numbers, this node's parameters, and:
|
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 —
|
node whose neutral is something else declares `active:` as an expression —
|
||||||
non-zero means active.
|
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
|
### Tests
|
||||||
|
|
||||||
| Key | Asserts |
|
| Key | Asserts |
|
||||||
|
|||||||
Reference in New Issue
Block a user