//! 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; `dr-widgets` 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WidgetKind { /// A tone curve, edited by dragging points on a grid. /// /// The underlying parameters are ordinary [`ParamKind::Scalar`]s — the /// point coordinates — so a UI that does not implement the curve widget /// can still present them as sliders and remain fully functional. That /// fallback is the reason the points are scalars rather than an opaque /// blob. Curve, } /// The shape of a parameter's value. #[derive(Debug, Clone, PartialEq)] pub enum ParamKind { Scalar { min: f32, max: f32, scale: Scale, unit: Unit, precision: u8, }, Bool, } /// 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 { pub widget: WidgetKind, /// The parameters this widget owns, in the order it expects 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], } /// One parameter of an operation. #[derive(Debug, Clone, PartialEq)] pub struct ParamDescriptor { pub id: ParamId, pub label: LocalizedKey, pub kind: ParamKind, pub default: f32, } 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, } } /// 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, } } /// 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, } } /// 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, } } /// 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`, and `const` functions // cannot use a builder's method chain. 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, } } /// 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 } } } } } /// 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 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); } }