//! 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::collections::HashSet; use std::fmt; use std::sync::{LazyLock, Mutex}; /// TRACES: FR-PLG-2 /// Give a string read at run time the `'static` lifetime the identifier types /// carry. /// /// # Why the identifiers stayed `&'static str` when the descriptors did not /// /// [`OpDescriptor`] became owned so a declaration read at *load* time can /// produce one (FR-PLG-2). The three identifier newtypes below deliberately /// did not follow it. /// /// An id is not content; it is a key. [`ParamId`] is `Copy`, is compared in /// `match` arms against the constants `build.rs` generates, is a map key in /// the sidecar and in history, and is threaded through `dr-ui` into Slint /// model rows. An `Arc` there would put a refcount on every one of those /// and would take `match id { EXPOSURE => .. }` away from the generated code — /// which is precisely the inspectability of the built-in chain that keeping /// the generated path was for. /// /// So ids are interned instead, and interning is honest about its lifetime /// rather than pretending to one. The set of interned ids is: /// /// - **Bounded.** One entry per *distinct* string, deduplicated on the way in. /// Parsing the same declaration a thousand times adds nothing after the /// first. /// - **Process-lifetime by construction.** A loaded declaration's vocabulary /// is never withdrawn. Nothing unloads a plugin, and nothing could: the /// sidecar on disk stores parameters by `(op_id, param_id)`, so an id has to /// stay resolvable for as long as any edit naming it can be opened. /// /// A leak whose bound is "the distinct ids this process has ever seen" is a /// different thing from one that grows with use, and this is the first. pub fn intern(s: &str) -> &'static str { static POOL: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); // A poisoned pool is still a correct pool: every entry in it is a // `&'static str` that was interned successfully, and a panic elsewhere // while the lock was held cannot have made one invalid. Refusing to // intern here would turn an unrelated panic into an application that can // no longer read a declaration. let mut pool = POOL.lock().unwrap_or_else(|e| e.into_inner()); if let Some(found) = pool.get(s) { return found; } let leaked: &'static str = Box::leak(s.to_owned().into_boxed_str()); pool.insert(leaked); leaked } /// Identifies a parameter within an operation. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct ParamId(pub &'static str); impl ParamId { /// The same id, from a name read out of a declaration at load time. /// /// Equal to `ParamId("exposure")` when the name is `"exposure"`: the /// derived `PartialEq` compares the `str` contents, not the pointer, which /// is what lets an interned id match a generated `match` arm. See /// [`intern`]. pub fn interned(name: &str) -> Self { Self(intern(name)) } } 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 OpId { /// The same id, from a declaration read at load time. See [`intern`]. pub fn interned(name: &str) -> Self { Self(intern(name)) } } 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); impl LocalizedKey { /// The same key, from a declaration read at load time. See [`intern`]. pub fn interned(key: &str) -> Self { Self(intern(key)) } } /// 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). /// /// Owned rather than `&'static`, for the reason [`OpDescriptor`] /// gives: a declaration parsed at load time has nowhere to put a /// `'static` slice. variants: Vec, }, } /// 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: Vec, /// 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: Vec, } 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. // // Not `const`, unlike its four siblings, and the reason is the `Vec` in // [`ParamKind::Enum`]: a heap allocation cannot happen in a const context. // Nothing is lost — every descriptor now lives inside a `LazyLock` // initialiser rather than a `static`, because `descriptor()` hands out an // `Arc` and an `Arc` is not const-constructible either. pub fn choice(id: &'static str, label: &'static str, variants: Vec) -> 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 eight // `&mut self` steps would be eight methods to say what one call already // says, and each one would be a place for a caller to forget a field. The // two common shapes have their own constructors above; this is the escape // hatch for the rest. // // Still `const` although no descriptor is a `static` any more: it costs // nothing, and it keeps the five constructors uniform where only `choice` // genuinely cannot be. #[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. // // No longer `const`: a `ParamDescriptor` can now carry a `Vec` (an enum's // variants), which gives the type drop glue, and assigning over a field of // such a type is not something a const function may do. Nothing is lost — // every descriptor is built inside a `LazyLock` initialiser now. pub 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, }) } } /// TRACES: FR-PLG-2 /// The description of an operation. /// /// # Owned, not `&'static` /// /// This used to be a `static` with `&'static [ParamDescriptor]` inside it, and /// [`crate::Operation::descriptor`] used to hand out a reference to it. That /// shape made a build-time node free and a run-time node **impossible**: a /// declaration parsed at startup has nothing to borrow from, so no amount of /// interpreting `ops/*.yaml` at load time could ever produce a descriptor the /// rest of the application would accept. FR-PLG-2 says a bundled operation and /// a third-party plugin are the same kind of thing, differing only in where /// the file was found — and a lifetime that only a compile-time literal can /// satisfy is exactly a second, weaker format for outsiders. /// /// So the descriptor owns its contents and is handed out as an /// `Arc`. The `Arc` rather than a `&self`-borrowed reference /// because the callers want to *keep* it: the develop panel collects /// descriptors and then mutates the graph, and a borrow would tie the /// descriptor's lifetime to a borrow of the operation it came from — which is /// the one thing `&'static` was doing right. /// /// The cost is a refcount per read, on a path that reads descriptors when a /// panel is built rather than per pixel. See `Operation::descriptor` for the /// one place that is read per composition and why it does not matter. #[derive(Debug, Clone, PartialEq)] pub struct OpDescriptor { pub id: OpId, pub label: LocalizedKey, pub params: Vec, /// What this operation is about (ARCH §4.3a). /// /// **Never empty**, and both the build-time and the load-time reader /// refuse 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: Vec, } 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); } }