//! 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 { 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, } /// 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, } 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/.yaml`. pub fn from_name(name: &str) -> Option { 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); } }