Show the colour mixer as three runs of twelve, each row a colour
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>
This commit is contained in:
2026-08-16 16:14:13 +02:00
co-authored by Claude Opus 5
parent ab4a7e00e7
commit 9b2ee0d0eb
10 changed files with 653 additions and 44 deletions
+83 -3
View File
@@ -113,6 +113,39 @@ pub struct Presentation {
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 {
@@ -120,6 +153,10 @@ pub struct ParamDescriptor {
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 {
@@ -136,6 +173,7 @@ impl ParamDescriptor {
precision: 2,
},
default: 0.0,
facet: None,
}
}
@@ -153,6 +191,7 @@ impl ParamDescriptor {
precision: 0,
},
default: 0.0,
facet: None,
}
}
@@ -163,6 +202,7 @@ impl ParamDescriptor {
label: LocalizedKey(label),
kind: ParamKind::Bool,
default: 0.0,
facet: None,
}
}
@@ -184,15 +224,19 @@ impl ParamDescriptor {
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`, 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.
// 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,
@@ -215,9 +259,23 @@ impl ParamDescriptor {
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
@@ -280,6 +338,28 @@ mod tests {
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
+8 -1
View File
@@ -7,7 +7,9 @@
//! Order is data, not code: operations run in the sequence this holds them,
//! so reordering the pipeline needs no code change.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation};
use crate::descriptor::{
Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::operation::{compose_with_framing, ComposedShader, Operation};
use crate::ops;
@@ -50,6 +52,9 @@ pub struct ParamCapability {
pub default: f32,
/// The current setting, so the control opens where the edit actually is.
pub value: f32,
/// Where this parameter sits among its siblings, when the operation's
/// parameters form a grid rather than a list. `None` for the usual case.
pub facet: Option<Facet>,
}
impl ParamCapability {
@@ -165,6 +170,7 @@ impl EditGraph {
kind: p.kind.clone(),
default: p.default,
value: op.param(p.id),
facet: p.facet,
})
.collect(),
presentation: op.presentation(),
@@ -187,6 +193,7 @@ impl EditGraph {
kind: p.kind.clone(),
default: p.default,
value: self.framing.param(p.id),
facet: p.facet,
})
.collect(),
// Framing is not an `Operation`, so it has no `presentation` to
+2 -2
View File
@@ -40,8 +40,8 @@ pub mod ops;
pub mod sidecar;
pub use descriptor::{
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation, Scale,
Unit, WidgetKind,
Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
Scale, Unit, WidgetKind,
};
pub use framing::{CropRect, Framing};
pub use graph::{EditGraph, OpCapability, ParamCapability};
+92 -17
View File
@@ -21,7 +21,7 @@
//! setting every band's saturation to +100 gives the same result as setting
//! the global saturation to +100 rather than something far stronger.
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::descriptor::{Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
use crate::operation::{Helper, Operation, Uniform};
use crate::ops::helpers;
@@ -114,43 +114,72 @@ impl Channel {
// Parameter descriptors, one per band per channel. Written out rather than
// generated because `ParamDescriptor` must be `const` to live in a `static`,
// and a const loop cannot build a slice. The macro keeps it honest.
//
// **Every one of them is faceted**, and that is what makes the operation
// legible in a panel. Thirty-six parameters presented as a flat list are
// thirty-six sliders reading "Hue / Sat / Lum" twelve times with nothing
// saying which band any row belongs to — the identity is right here in the
// descriptor and used to be discarded on the way out. The facet carries it:
// the channel as the aspect, the band as the subject, and the band's centre
// hue so a frontend can identify the row by the colour it edits rather than
// by a word. What a frontend *does* with 30° is its own business (ARCH
// §4.3a); this only says the parameter acts on the band centred there.
macro_rules! band_params {
($($key:literal),* $(,)?) => {
($(($key:literal, $hue:literal)),* $(,)?) => {
&[
$(
ParamDescriptor::amount(
concat!($key, "_hue"),
concat!("param.mixer.", $key, ".hue"),
),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.hue"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
ParamDescriptor::amount(
concat!($key, "_sat"),
concat!("param.mixer.", $key, ".sat"),
),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.sat"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
ParamDescriptor::amount(
concat!($key, "_lum"),
concat!("param.mixer.", $key, ".lum"),
),
)
.faceted(Facet {
aspect: LocalizedKey("param.channel.lum"),
subject: LocalizedKey(concat!("band.", $key)),
subject_hue: Some($hue),
}),
)*
]
};
}
// A fourth table parallel to `BANDS` and the three uniform-name tables, for
// the same reason as those: `concat!` needs literals, so the keys and hues
// cannot be read out of `BANDS` here. `facets_match_their_bands` below is
// what keeps them from drifting.
static DESCRIPTOR: OpDescriptor = OpDescriptor {
id: ID,
label: LocalizedKey("op.colour_mixer"),
params: band_params![
"red",
"orange",
"yellow",
"chartreuse",
"green",
"spring",
"cyan",
"azure",
"blue",
"violet",
"magenta",
"rose",
("red", 0.0),
("orange", 30.0),
("yellow", 60.0),
("chartreuse", 90.0),
("green", 120.0),
("spring", 150.0),
("cyan", 180.0),
("azure", 210.0),
("blue", 240.0),
("violet", 270.0),
("magenta", 300.0),
("rose", 330.0),
],
};
@@ -501,6 +530,52 @@ mod tests {
}
}
#[test]
fn facets_match_their_bands() {
// The macro's `(key, hue)` list is a fourth table parallel to `BANDS`,
// and a hue mistyped there would put a row's swatch on a colour the
// band does not act on — a control that lies about what it edits,
// which is worse than one with no swatch at all.
for p in DESCRIPTOR.params {
let facet = p.facet.expect("every mixer parameter is faceted");
let (band_key, _) = p.id.0.rsplit_once('_').expect("id is band_channel");
let band = BANDS
.iter()
.find(|b| b.key == band_key)
.expect("the id names a band");
assert_eq!(
facet.subject.0,
format!("band.{band_key}"),
"{} is subject to the wrong band",
p.id
);
assert_eq!(
facet.subject_hue,
Some(band.hue),
"{} claims a hue its band does not have",
p.id
);
}
}
#[test]
fn each_channel_is_one_aspect_across_every_band() {
// What lets a panel name the run once instead of twelve times: the
// twelve hue parameters must agree they are the same control. Were
// the aspect keyed per band, grouping by it would produce thirty-six
// groups of one and nothing would have been gained.
let mut per_aspect = std::collections::BTreeMap::new();
for p in DESCRIPTOR.params {
let facet = p.facet.expect("faceted");
*per_aspect.entry(facet.aspect.0).or_insert(0) += 1;
}
assert_eq!(per_aspect.len(), Channel::ALL.len());
for (aspect, count) in per_aspect {
assert_eq!(count, BANDS.len(), "{aspect} does not cover every band");
}
}
#[test]
fn a_fresh_mixer_is_inactive() {
assert!(!ColourMixer::new().is_active());