Files
DarkRoom/core/dr-pipeline/src/descriptor.rs
T
dtourolleandClaude Opus 5 0a331c717e Give the controls a vocabulary, and let a node ask for one
widgets.slint set the rule — screens consume components, and a bare `Theme.*`
at a call site means a component is missing — and it set it for chrome only.
The controls never got the same treatment, so they were written wherever they
were first needed and copied from there.

**The slider was private to the develop panel.** `SliderTrack`, with the
fifty-line preamble explaining how it wrests a drag away from a Flickable,
lived inside adjust.slint and no other screen could reach it. It shows: export
quality is a 1-to-100 value, and the settings page offered a free-text box for
it, with the range written in a hint and enforced nowhere. `to-float()` answers
0 for anything it cannot parse, so a typo saved a quality of 0 and the page
displayed the 0 back as though it had been asked for.

The tick-box was written twice, in launch.slint and settings.slint, from the
same 18px box and the same handler; the second carried a comment deferring the
lift until a third caller appeared. The label-and-hint header was written three
times inside settings.slint alone.

controls.slint is the input layer beside widgets.slint's chrome layer, and the
constraint that makes it reusable is that **nothing in it knows about
`ParamRow`** — that struct is the develop panel's flattening of the capability
model, and a control that imported it could only ever be used by the develop
panel. The primitives take plain numbers; the ParamRow-shaped wrappers stay in
the panel that owns the model. 658 lines came out of the three screens.

`SliderRow` is the slider-plus-number-box ARCH §4.3 names as the pointer
presentation of a bounded scalar, and quality is its first adopter. It commits
on gesture end rather than on every movement, because the settings page saves
to disk on change and a two-second drag is a couple of hundred writes where a
text field committed once. The develop panel keeps the live stream — that is
what its pipeline is for — so `SliderTrack` now reports both.

**The other half is the descriptor.** FR-DEV-3a and ARCH §4.3a already specify
more than was built: an ordered preference list of widgets rather than one, the
demands a widget makes, and kinds beyond scalar and bool.

- `Presentation.widgets` is now a list, walked by `choose`, falling back to
  plain sliders. Falling off the end is not an error, and there is a test
  asserting an operation asking only for an unimplemented widget still yields
  one control per parameter.
- `WidgetDemand` carries what a widget inherently needs — two-dimensional
  dragging, precise pointing — and no pixels, breakpoints or platform names.
- `WidgetKind` grows to the specified set. There is deliberately no `Colour`
  *kind*: a colour is three numbers, and a value type that is not an `f32`
  would reach through the graph, the uniform block and the sidecar format to
  buy what `ColourWheel` over three scalars already describes. Every widget
  here is a hint over ordinary scalars, which is what keeps the fallback
  honest.
- `ParamKind::Enum` is the one new shape, and it fits because a variant index
  is exact in binary32. `kind: enum` with a `variants:` list works in
  `ops/*.yaml`, so a node declaring one gets a segmented control with no UI
  file edited — which is the promise ops/mod.rs already makes.

The panel's dispatch was duplicated: a lone parameter and a grouped one each
wrote out their own list of kinds, so `enum` would have had to be added twice
and a kind added to one would appear or vanish depending on how many parameters
its operation happened to declare. `ParamControl` is now the only such chain.

`rows_from` is free-standing rather than a method, which is what lets the
FR-DEV-3c acceptance test requirements.md asks for actually be written: an
operation the frontend has never heard of, appearing in a generated panel, with
no GPU in sight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:21:35 +02:00

519 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Parameter descriptors — operations described as data (ARCH §3.3).
//!
//! The core never builds a control. It publishes what its parameters *are*,
//! and `dr-ui` maps each `ParamKind` to a widget appropriate to the current
//! input modality (ARCH §4.3). Adding an operation therefore needs no UI
//! change (FR-DEV-3c).
//!
//! Labels are keys, not strings: resolving them needs a localiser, and
//! `core/` must not depend on one (NFR-A11Y-1).
use std::fmt;
/// Identifies a parameter within an operation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ParamId(pub &'static str);
impl fmt::Display for ParamId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// Identifies an operation kind.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OpId(pub &'static str);
impl fmt::Display for OpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.0)
}
}
/// A localisation key. The UI resolves it; the core never sees the string.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LocalizedKey(pub &'static str);
/// What a slider's travel means.
///
/// Photographic controls are rarely linear in their underlying quantity:
/// exposure is linear in stops but exponential in light, and a temperature
/// slider that is linear in kelvin feels wrong at both ends.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scale {
Linear,
/// Even *perceptual* steps across the range, for controls whose effect
/// concentrates near one end.
Perceptual,
}
/// The unit a value carries, for display.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Unit {
None,
/// Exposure value — photographers think in stops, not multipliers.
Stops,
Kelvin,
Percent,
}
/// A control that does not reduce to a slider or a switch.
///
/// The core names the *kind* of widget; the frontend owns what it looks like
/// and how it behaves (ARCH §3.3). This is deliberately a small closed enum
/// rather than an open string: a UI must be able to match exhaustively and
/// know it has covered everything the core can ask for.
///
/// **Every one of these is a hint over ordinary scalar parameters**, never a
/// new kind of value. A curve is its point coordinates, a colour wheel is
/// three numbers, a crop is four edges — all [`ParamKind::Scalar`], all
/// individually addressable, all persisted by the sidecar with no special
/// case. That is what makes the fallback in [`Presentation::widgets`] honest:
/// a frontend that implements none of these still renders every parameter as
/// a slider and the edit works, merely more tediously.
///
/// It is also why there is no `Colour` *kind*. A colour is three or four
/// numbers, and introducing a value type that is not an `f32` would reach
/// through the graph, the uniform block and the sidecar format to buy a
/// control that [`ColourWheel`](WidgetKind::ColourWheel) already describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WidgetKind {
/// A tone curve, edited by dragging points on a grid.
///
/// The underlying parameters are the point coordinates, x and y
/// interleaved.
ToneCurve,
/// Colour grading wheels: a hue-and-strength pad per tonal range.
ColourWheel,
/// On-canvas crop and straighten handles. Parameters are the four crop
/// edges as fractions, and the straighten angle.
CropOverlay,
/// On-canvas placement of a linear or radial mask.
GradientHandle,
/// On-canvas brush strokes.
BrushMask,
/// An eyedropper bound to the canvas, setting white balance from a pixel.
WhitePoint,
}
impl WidgetKind {
/// TRACES: FR-UI-7
/// Whether this widget is manipulated on the photograph rather than in a
/// panel.
///
/// A property of the widget itself, not of the screen: a crop is dragged
/// on the image wherever the image is, and that is true of a phone and a
/// workstation alike. Where the panel then *puts* the affordance that
/// turns it on, and how large its handles are, stay frontend decisions
/// (ARCH §4.3a).
pub fn is_on_canvas(self) -> bool {
matches!(
self,
Self::CropOverlay | Self::GradientHandle | Self::BrushMask | Self::WhitePoint
)
}
}
/// TRACES: FR-DEV-3a
/// What a widget inherently needs in order to be usable.
///
/// **Demands describe the control, not the screen** (ARCH §4.3a). A curve
/// needs two-dimensional pointing and a certain amount of room to be worth
/// drawing at all; those are facts about curves. Whether *this* window has
/// that room, at what breakpoint, on what platform, is the frontend's
/// question, and a demand carrying pixels or a platform name would be the core
/// answering it — a core that reasons about pixels will eventually be wrong
/// about a display it never saw.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct WidgetDemand {
/// Needs the value to be dragged in two dimensions at once. A frontend
/// with only a keyboard, or a strictly linear input, should skip it.
pub two_dimensional: bool,
/// Needs pointing accurate to a small fraction of the control. A frontend
/// driving a television with a remote should skip it; touch is fine, since
/// hit regions grow to the modality (FR-UI-7).
pub precise_pointing: bool,
}
/// The shape of a parameter's value.
///
/// **Every variant is carried as an `f32`.** That is not an implementation
/// detail to be tidied away later — it is what lets one storage path serve
/// every parameter: the sidecar writes a number, the uniform block takes a
/// number, and `set_param` is one function rather than one per shape. A
/// variant that needed a richer value would reach through all three, which is
/// why a colour is a [`WidgetKind::ColourWheel`] over three scalars rather
/// than a kind of its own.
#[derive(Debug, Clone, PartialEq)]
pub enum ParamKind {
Scalar {
min: f32,
max: f32,
scale: Scale,
unit: Unit,
precision: u8,
},
Bool,
/// TRACES: FR-DEV-3a
/// One of a short, fixed list of named alternatives.
///
/// The value is the chosen variant's **index**, held as an `f32` like
/// everything else — small integers are exact in binary32, so this costs
/// nothing in fidelity and keeps the parameter on the ordinary storage
/// path.
///
/// Distinct from a `Scalar` running 0..n because the numbers are not on a
/// scale: interpolating between two of them is meaningless, dragging
/// through them is not a gesture anyone wants, and the labels are the
/// whole point. A UI that treated this as a scalar would render a slider
/// reading "2" where the user needs to see "Bicubic".
Enum {
/// In index order. The label is a localisation key, resolved by the
/// frontend — `core/` must not depend on a localiser (NFR-A11Y-1).
variants: &'static [LocalizedKey],
},
}
/// TRACES: FR-DEV-3a | FR-DEV-3b
/// How an operation would like its parameters presented.
///
/// A *hint*, never a requirement. An operation's parameters are always
/// individually addressable scalars; this only says that several of them
/// form one conceptual control, and which widget draws it best. A UI is free
/// to ignore it entirely and render plain sliders — the edit still works, it
/// is merely more tedious.
///
/// Sitting on the operation rather than on a parameter is what allows a
/// widget to span several parameters, which a curve necessarily does.
///
/// Declared through [`crate::Operation::presentation`] — a defaulted trait
/// method rather than a field on [`OpDescriptor`], so the great majority of
/// operations, which want plain sliders, say nothing at all.
#[derive(Debug, Clone, PartialEq)]
pub struct Presentation {
/// Widgets that would draw these parameters, **in descending order of
/// preference** (ARCH §4.3a).
///
/// The frontend walks the list and takes the first it both implements and
/// can afford. Falling off the end is not an error: every parameter
/// remains an individually addressable scalar, so plain sliders are always
/// the final fallback and the edit still works.
///
/// A list rather than one kind because the alternatives are real. A colour
/// grading operation is best as a wheel, acceptable as a hue-and-strength
/// pair of sliders, and an operation that can say so gets a good control
/// on a workstation and a usable one on a phone without the core knowing
/// which it is talking to.
pub widgets: &'static [WidgetKind],
/// What the preferred widget needs in order to be worth drawing.
///
/// Applies to the list as a whole rather than per entry: a frontend that
/// cannot meet the demand skips to plain sliders, which is the same answer
/// it gives for a widget it has not implemented.
pub demand: WidgetDemand,
/// The parameters these widgets own, in the order they expect them.
///
/// Parameters absent from this list are presented normally, so an
/// operation can pair a curve with an ordinary strength slider.
pub params: &'static [ParamId],
}
impl Presentation {
/// The first widget in the preference list that `supported` accepts.
///
/// The walk lives here rather than in each frontend so that "first
/// supported, else sliders" is written once and cannot drift between a
/// desktop UI, a test harness and whatever consumes capabilities next.
pub fn choose(&self, supported: impl Fn(WidgetKind) -> bool) -> Option<WidgetKind> {
self.widgets.iter().copied().find(|w| supported(*w))
}
}
/// TRACES: FR-DEV-3a
/// A parameter's place in an operation whose parameters form a grid.
///
/// Most operations are a short list of unrelated controls. A few are the
/// *same* control applied to a series of subjects: the colour mixer is twelve
/// hue bands times hue, saturation and luminance, and rendered as a flat list
/// of thirty-six it says none of that — the panel showed "Hue / Sat / Lum"
/// twelve times over with nothing naming the band.
///
/// So a parameter may say which **aspect** it adjusts and which **subject** it
/// adjusts it on. A UI is free to ignore both and render a flat list; nothing
/// becomes unreachable, it merely reads as thirty-six anonymous sliders again.
///
/// **Why this is not the core deciding presentation** (ARCH §4.3a). Which
/// band a parameter belongs to, and that its centre is at 30°, are facts about
/// what the operation *does* — the mixer genuinely weights pixels around 30°,
/// and that number is the one it weights around. What colour to draw from it,
/// at what saturation, whether to draw anything at all, and in what order to
/// stack the runs are all presentation, and stay in `dr-ui`. The line: a hue
/// in degrees is data; a hex colour in a descriptor would be the core choosing
/// appearance, and is forbidden.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Facet {
/// What this parameter adjusts. Parameters sharing an aspect are one
/// control applied to different subjects.
pub aspect: LocalizedKey,
/// What it adjusts it on.
pub subject: LocalizedKey,
/// Where the subject sits on the hue wheel, in degrees, where the subject
/// is a colour. `None` for one that is not.
pub subject_hue: Option<f32>,
}
/// One parameter of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct ParamDescriptor {
pub id: ParamId,
pub label: LocalizedKey,
pub kind: ParamKind,
pub default: f32,
/// Where this parameter sits among its siblings, for an operation whose
/// parameters form a grid. `None` — the usual case — is a parameter that
/// stands on its own.
pub facet: Option<Facet>,
}
impl ParamDescriptor {
/// A scalar in stops — the exposure-like controls.
pub const fn stops(id: &'static str, label: &'static str, min: f32, max: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale: Scale::Linear,
unit: Unit::Stops,
precision: 2,
},
default: 0.0,
facet: None,
}
}
/// A symmetric −100…+100 control, the familiar shape for tone and colour
/// adjustments. Neutral at zero, so a double-tap reset is meaningful.
pub const fn amount(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: -100.0,
max: 100.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 0,
},
default: 0.0,
facet: None,
}
}
/// A toggle. Neutral when off, so the reset contract still holds.
pub const fn switch(id: &'static str, label: &'static str) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Bool,
default: 0.0,
facet: None,
}
}
/// One of a fixed list of alternatives, defaulting to the first.
///
/// The first rather than a caller-chosen index, so the reset contract
/// holds the way it does for every other kind: index 0 is the neutral
/// choice, and an operation whose default is not its first variant has
/// listed them in the wrong order.
pub const fn choice(
id: &'static str,
label: &'static str,
variants: &'static [LocalizedKey],
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Enum { variants },
default: 0.0,
facet: None,
}
}
/// A 0…1 fraction — a proportion of something, rather than an amount.
///
/// Its own constructor because the crop rect needs four of them and the
/// default differs per edge: an origin starts at 0 and an extent at 1.
/// Precision of 4 because at 6000px a step of 0.0001 is under a pixel,
/// and a coarser one would make a crop edge unplaceable.
pub const fn fraction(id: &'static str, label: &'static str, default: f32) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: Scale::Linear,
unit: Unit::None,
precision: 4,
},
default,
facet: None,
}
}
/// A general scalar with an explicit range and default.
//
// Eight arguments, and a builder would be the usual answer — but this has
// to be `const` so descriptors can be `static`, which rules out the
// `&mut self` builder the pattern usually takes. (A `self`-by-value step
// *is* const-callable — `faceted` below is one — but eight of them would
// be eight methods to say what one call already says.) The two common
// shapes have their own constructors above; this is the escape hatch for
// the rest.
#[allow(clippy::too_many_arguments)]
pub const fn scalar(
id: &'static str,
label: &'static str,
min: f32,
max: f32,
default: f32,
unit: Unit,
scale: Scale,
precision: u8,
) -> Self {
Self {
id: ParamId(id),
label: LocalizedKey(label),
kind: ParamKind::Scalar {
min,
max,
scale,
unit,
precision,
},
default,
facet: None,
}
}
/// The same parameter, placed in its operation's grid.
///
/// A method rather than a sixth constructor, because a facet is orthogonal
/// to the shape of the value: a faceted parameter is still an amount, or
/// still a scalar in stops, and pairing every constructor with a faceted
/// twin would double the list above to say one thing. Taking `self` by
/// value is what keeps it usable in the `static` descriptors — a `&mut
/// self` builder is what cannot be `const`.
pub const fn faceted(mut self, facet: Facet) -> Self {
self.facet = Some(facet);
self
}
/// Clamp a value into this parameter's declared range.
///
/// Applied before the value reaches a shader: a slider dragged past its
/// bounds, or a sidecar written by a newer version with a wider range,
/// must not produce out-of-range uniforms.
pub fn clamp(&self, value: f32) -> f32 {
match self.kind {
ParamKind::Scalar { min, max, .. } => {
if value.is_finite() {
value.clamp(min, max)
} else {
// A NaN from a corrupt sidecar would otherwise poison the
// uniform block and blank the image.
self.default
}
}
ParamKind::Bool => {
if value != 0.0 {
1.0
} else {
0.0
}
}
// Rounded before clamping, because the value arriving here is an
// `f32` that has been through a sidecar and possibly a slider: an
// index of 1.9999 is variant 2, and truncating it to 1 would
// silently select the wrong option. A non-finite index falls back
// to the default for the same reason a scalar does.
ParamKind::Enum { variants } => {
if value.is_finite() {
let last = variants.len().saturating_sub(1) as f32;
value.round().clamp(0.0, last)
} else {
self.default
}
}
}
}
}
/// The static description of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
}
impl OpDescriptor {
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
self.params.iter().find(|p| p.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
const P: ParamDescriptor = ParamDescriptor::amount("test", "test.label");
#[test]
fn values_clamp_into_range() {
assert_eq!(P.clamp(150.0), 100.0);
assert_eq!(P.clamp(-150.0), -100.0);
assert_eq!(P.clamp(42.0), 42.0);
}
#[test]
fn a_nan_falls_back_to_the_default_rather_than_poisoning_the_uniform() {
// A corrupt sidecar must not blank the image: one NaN in a uniform
// block propagates through every pixel.
assert_eq!(P.clamp(f32::NAN), P.default);
assert_eq!(P.clamp(f32::INFINITY), P.default);
}
#[test]
fn a_parameter_stands_alone_unless_it_says_otherwise() {
// The default has to be "no grid": every operation but the mixer is a
// short list of unrelated controls, and one that accidentally claimed
// a facet would have its panel section split under a heading it never
// asked for.
assert!(P.facet.is_none());
assert!(ParamDescriptor::switch("s", "s").facet.is_none());
let faceted = P.faceted(Facet {
aspect: LocalizedKey("param.channel.sat"),
subject: LocalizedKey("band.orange"),
subject_hue: Some(30.0),
});
// Placing a parameter in a grid must not change what the parameter
// *is* — the value it carries, its range and its default are the same
// either way.
assert_eq!(faceted.kind, P.kind);
assert_eq!(faceted.default, P.default);
assert_eq!(faceted.facet.unwrap().subject_hue, Some(30.0));
}
#[test]
fn amount_controls_are_neutral_at_zero() {
// Double-tap-to-reset and "is this op doing anything" both depend on
// neutral being zero.
assert_eq!(P.default, 0.0);
}
}