Build and test / Desktop (Linux) (push) Successful in 17m20s
Build and test / Layer separation (push) Successful in 33s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Traceability / Requirement traces (push) Failing after 26s
Build and test / Android (aarch64) (push) Failing after 8m59s
The mixer was thirty-six sliders reading "Hue / Sat / Lum" twelve times over with nothing saying which band any row belonged to. The identity was there all along — the descriptor declares param.mixer.orange.sat and BANDS carries orange at 30° — and was discarded on the way out: labels.rs had no mixer entries, so every key fell through to a derived label that yields the bare channel name. A parameter can now say which aspect it adjusts and which subject it adjusts it on, with the subject's hue where the subject is a colour (descriptor::Facet). That is data about what the operation does, not a layout: the mixer genuinely weights pixels around 30°. What to draw from 30°, and in what order to stack the runs, stay in dr-ui (ARCH §4.3a) — develop.rs brings rows sharing an aspect together and marks the first of each, and adjust.slint names the run once and draws a swatch, a track and a readout on one line. Grouped by channel rather than by band because an edit is almost never "everything about orange"; it is the saturation of the greens, made by comparing one channel across neighbouring bands. Twelve band sections put those twelve rows in twelve different places. The swatch is the label, which is what makes twelve rows fit where four did. The band name is not lost: it is the row's accessible label, so the control is not colour-only, and labels.rs is where the mapping is written down — including chartreuse as "Yellow-Green" and spring as "Blue-Green", since nobody hunting foliage scans a list for "Spring". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
370 lines
13 KiB
Rust
370 lines
13 KiB
Rust
//! 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],
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
}
|
||
|
||
/// 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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|