Files
DarkRoom/core/dr-pipeline/src/descriptor.rs
T
dtourolle 7421837c8a Let an operation say what it is about, so the panel can group without naming
Tool tabs need a taxonomy, and the taxonomy was the problem: a table in
`ui/` mapping operation to tab breaks FR-DEV-3a, and a `group:` field risks
what `ui-refinement.md` condemned `starts-group` for — the core deciding
where the panel draws things.

`Attribute` threads the needle. It says what an operation *is* — tone,
colour, detail, optics, geometry, effect — which is the same category as
`ParamKind` and squarely on the core's side of ARCH §4.3a's line. What is
drawn, where it sits and whether it is visible stay the frontend's. There is
no attribute for "the third tab", the enum's order is declaration order
rather than screen order, and a frontend may render these as tabs, as
headings, or ignore them.

The payoff is that a tab strip can be *derived*: the groups are the
attributes present in the capability list, so the interface names no
operation and needs no table to keep in step. An operation joins the right
group by declaring what it is, which is the one thing its author is well
placed to say.

Plural, because the tone curve is genuinely both — an RGB curve is tonal and
the per-channel curves are chromatic, and filing it under one would hide it
from half the people looking for it.

Required and non-empty, enforced in `build.rs`, and the failure was checked
by removing the line rather than assumed. An operation with no attribute is
invisible to a panel that groups by them; a build that stops costs ten
seconds, a control nobody can find costs more. The vocabulary is closed for
the same reason: a typo would otherwise invent a category holding exactly one
operation, which looks like a deliberate one until somebody counts.

Six tests over the real chain, including the hand-written operations that
`build.rs` never sees and so cannot check.
2026-08-22 10:10:00 +02:00

624 lines
24 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
}
}
}
}
}
/// What an operation *is about*.
///
/// A statement of the operation's nature, in the same category as
/// [`ParamKind`]: the core saying what a thing is, not where it is drawn. A
/// frontend may render these as tabs, as section headings, as a filter, or
/// ignore them entirely — that choice is composition and belongs to whoever
/// knows the window (ARCH §4.3a).
///
/// # Why the core may say this at all
///
/// The line §4.3a draws is between *what a thing is* and *what is drawn,
/// where it sits, how wide it is, and whether it is visible*. "White balance
/// is a colour operation" is the first kind. It is also knowledge the core is
/// uniquely placed to hold: the person adding an operation knows what it does,
/// and a frontend that had to work it out would be doing so by matching on the
/// operation's name — which is the one thing `ui/` may never do (FR-DEV-3a).
///
/// What this deliberately is **not** is a tab name. There is no `Attribute`
/// for "the third tab", the order below is declaration order rather than
/// screen order, and an operation carrying two attributes appears wherever the
/// frontend decides that means — twice, once, or nowhere.
///
/// # Plural on purpose
///
/// An operation may carry several. The tone curve is genuinely both tonal and
/// chromatic — it has an RGB curve and per-channel curves — and forcing it to
/// pick one would file it away from half the people looking for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Attribute {
/// Lightness and its distribution: exposure, contrast, the recovery
/// controls, the tone curve.
Tone,
/// Hue and saturation: white balance, the colour mixer, vibrance.
Colour,
/// Acutance and noise — what the image is made of at the pixel level.
/// Sharpening, noise reduction, texture, clarity.
Detail,
/// Corrections for the lens that took the photograph: distortion,
/// chromatic aberration, vignetting.
Optics,
/// The shape of the frame: crop, straighten, rotation, flips.
Geometry,
/// Applied rather than corrected — a look, not a fix.
Effect,
}
impl Attribute {
/// Every attribute, in declaration order.
///
/// Declaration order is roughly the order a photographer works in, which
/// makes it a reasonable *default* for a frontend that wants one. It is
/// not a screen order: nothing here obliges a frontend to show them all,
/// show them in this sequence, or show them at all.
pub const ALL: [Attribute; 6] = [
Attribute::Tone,
Attribute::Colour,
Attribute::Detail,
Attribute::Optics,
Attribute::Geometry,
Attribute::Effect,
];
/// The localisation key naming this concept.
///
/// Naming the concept, the way an operation's own `label` names the
/// operation. What a frontend *does* with the name — a tab, a heading,
/// nothing — is still its own affair.
pub fn label(self) -> LocalizedKey {
LocalizedKey(match self {
Self::Tone => "attr.tone",
Self::Colour => "attr.colour",
Self::Detail => "attr.detail",
Self::Optics => "attr.optics",
Self::Geometry => "attr.geometry",
Self::Effect => "attr.effect",
})
}
/// Parse the name used in `ops/<id>.yaml`.
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"tone" => Self::Tone,
"colour" => Self::Colour,
"detail" => Self::Detail,
"optics" => Self::Optics,
"geometry" => Self::Geometry,
"effect" => Self::Effect,
_ => return None,
})
}
}
/// The static description of an operation.
#[derive(Debug, Clone, PartialEq)]
pub struct OpDescriptor {
pub id: OpId,
pub label: LocalizedKey,
pub params: &'static [ParamDescriptor],
/// What this operation is about (ARCH §4.3a).
///
/// **Never empty**, and `build.rs` refuses to generate an operation that
/// declares none. An operation with no attribute would be invisible to a
/// frontend that filters by them, and a control that silently does not
/// exist is a worse failure than a build that stops — particularly when
/// the cause would be a missing line in a YAML file nobody looked at.
pub attributes: &'static [Attribute],
}
impl OpDescriptor {
pub fn param(&self, id: ParamId) -> Option<&ParamDescriptor> {
self.params.iter().find(|p| p.id == id)
}
/// Whether this operation is about `attribute`.
pub fn has(&self, attribute: Attribute) -> bool {
self.attributes.contains(&attribute)
}
}
#[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);
}
}