diff --git a/core/dr-pipeline/build.rs b/core/dr-pipeline/build.rs index 3f67af8..90a1fdc 100644 --- a/core/dr-pipeline/build.rs +++ b/core/dr-pipeline/build.rs @@ -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, + /// The owned parameters' generated `ParamId` constants, in widget order. + params: Vec, + 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, tests: Vec, + /// `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>, }, Rust { id: String, @@ -414,6 +429,7 @@ fn read_node(path: &Path, shared: &BTreeSet<&str>) -> Result { .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 { 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>, 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::, _>>()?; + + // 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::, _>>()?; + + let demand = match m.get("demand") { + None => (false, false), + Some(d) => { + let d = as_mapping(d, "presentation.demand")?; + let flag = |key: &str| -> Result { + 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, 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 {{\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); diff --git a/core/dr-pipeline/ops/README.md b/core/dr-pipeline/ops/README.md index 454dcca..f3669c6 100644 --- a/core/dr-pipeline/ops/README.md +++ b/core/dr-pipeline/ops/README.md @@ -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 |