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
+175 -2
View File
@@ -130,7 +130,13 @@ impl DevelopSession {
let group_modified = op.params.iter().any(|p| p.value != p.default);
let group_len = op.params.len() as i32;
for (param_index, p) in op.params.iter().enumerate() {
// The aspect the previous row belonged to, so a run can be told
// from its continuation. Reset per operation: two operations that
// happened to facet on the same key are still two groups.
let mut previous_aspect: Option<&str> = None;
for param_index in presentation_order(&op.params) {
let p = &op.params[param_index];
let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar {
min,
@@ -148,11 +154,29 @@ impl DevelopSession {
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
};
// A faceted parameter is named by its *subject* — the band —
// because its aspect is already written above the run it sits
// in. Unfaceted parameters keep their own label, which is
// every operation but the mixer.
let param_label = match &p.facet {
Some(f) => labels::resolve(f.subject.0),
None => labels::resolve(p.label.0),
};
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
let starts_facet = aspect.is_some() && aspect != previous_aspect;
previous_aspect = aspect;
rows.push(ParamRow {
op_index: op_index as i32,
param_index: param_index as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: labels::resolve(p.label.0).into(),
param_label: param_label.into(),
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
starts_facet,
// -1 rather than an `Option`, which a Slint struct cannot
// carry: 0° is red, so no value in range can stand for
// "no swatch".
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
group_head: group_head as i32,
group_len,
group_modified,
@@ -216,6 +240,11 @@ impl DevelopSession {
param_index: base as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: String::new().into(),
// A widget spanning a whole operation is not a row in anyone's
// grid, so it heads no run and carries no swatch.
facet_label: String::new().into(),
starts_facet: false,
swatch_hue: -1.0,
group_head: group_head as i32,
// One widget standing for every parameter of the operation, so
// the group it heads is itself and nothing else.
@@ -724,6 +753,47 @@ fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
)
}
/// The order an operation's parameters are shown in.
///
/// Declaration order, unless the operation facets them — in which case
/// parameters sharing an aspect are brought together, so the panel names
/// each run once instead of repeating "Hue / Saturation / Luminance"
/// twelve times over. The colour mixer declares band by band, which is the
/// order the shader wants; a photographer works channel by channel.
///
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
/// says which aspect a parameter belongs to; deciding that an aspect is
/// worth stacking rows by is the panel's composition to make, exactly as
/// grouping by operation is. Routing is unaffected — `param_index` stays
/// the position in the capability list however the rows are stacked.
///
/// A stable sort by the aspect's first appearance, so an operation with no
/// facets comes back untouched, and one that mixes plain parameters with
/// faceted ones keeps the plain ones first and in order.
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
let mut aspects: Vec<&str> = Vec::new();
let rank: Vec<usize> = params
.iter()
.map(|p| match &p.facet {
None => 0,
Some(f) => {
let at = aspects.iter().position(|a| *a == f.aspect.0);
// First appearance defines the run's place, so the panel's
// sections come out in the order the operation introduced
// them rather than alphabetically.
1 + at.unwrap_or_else(|| {
aspects.push(f.aspect.0);
aspects.len() - 1
})
}
})
.collect();
let mut order: Vec<usize> = (0..params.len()).collect();
order.sort_by_key(|i| rank[*i]);
order
}
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
/// this function needs no knowledge of which parameter it is formatting.
fn unit_suffix(unit: Unit) -> &'static str {
@@ -1151,6 +1221,109 @@ mod tests {
assert!(rotate_crop(CropRect::default(), -3).is_full());
}
#[test]
fn an_operation_without_facets_keeps_its_declared_order() {
// Every operation but the mixer. Reordering one of these would move
// Highlights below Shadows for no reason anybody could see in the
// code, so the stable sort has to be a no-op when nothing is faceted.
let graph = EditGraph::default_chain();
for cap in graph.capabilities() {
if cap.params.iter().any(|p| p.facet.is_some()) {
continue;
}
let order = presentation_order(&cap.params);
assert_eq!(
order,
(0..cap.params.len()).collect::<Vec<_>>(),
"{} was reordered",
cap.id
);
}
}
#[test]
fn faceted_parameters_are_stacked_one_run_per_aspect() {
// The panel names a run once and then draws its rows. That only works
// if a run is *contiguous*: the mixer declares band by band — red hue,
// red sat, red lum, orange hue — so shown in declaration order every
// single row would begin a new run, and the panel would draw
// thirty-six headings over thirty-six sliders.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
.expect("the chain has a faceted operation");
let mut seen: Vec<&str> = Vec::new();
let mut previous: Option<&str> = None;
for i in presentation_order(&cap.params) {
let aspect = cap.params[i]
.facet
.as_ref()
.expect("this operation facets every parameter")
.aspect
.0;
if previous != Some(aspect) {
assert!(
!seen.contains(&aspect),
"{aspect} is split into two runs — a heading would be \
drawn over each half"
);
seen.push(aspect);
previous = Some(aspect);
}
}
assert!(seen.len() > 1, "the fixture must have several aspects");
}
#[test]
fn reordering_rows_does_not_move_where_a_change_is_routed() {
// The rows are stacked for reading; `param_index` still addresses the
// capability list. Were the two confused, dragging a band's Hue would
// silently write to whichever parameter happened to sit at that
// position — an edit landing on the wrong control, which reads as the
// renderer being broken rather than the panel.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
.expect("the chain has a faceted operation");
let mut order = presentation_order(&cap.params);
order.sort_unstable();
assert_eq!(
order,
(0..cap.params.len()).collect::<Vec<_>>(),
"the order must be a permutation: every parameter reachable from \
exactly one row, and every row addressing a parameter that exists"
);
}
#[test]
fn every_faceted_parameter_resolves_to_a_band_name() {
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
// all thirty-six keys fell through to a derived label that yields the
// bare channel name — twelve rows reading "Hue" with nothing saying
// which band. A row identified only by a swatch depends on this
// resolving, since the name is what a screen reader speaks and what
// anyone who cannot separate two squares by eye has to go on.
let graph = EditGraph::default_chain();
for cap in graph.capabilities() {
for p in &cap.params {
let Some(facet) = &p.facet else { continue };
let subject = labels::resolve(facet.subject.0);
let aspect = labels::resolve(facet.aspect.0);
assert!(!subject.is_empty(), "{} has no subject name", p.id);
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
// Not the channel name repeated: that is exactly the failure
// the catalogue entries were added to fix.
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
}
}
}
#[test]
fn unit_suffixes_come_from_the_descriptor() {
assert_eq!(unit_suffix(Unit::Stops), " EV");