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
+163 -7
View File
@@ -8,7 +8,7 @@
// (FR-DEV-3c).
import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton } from "widgets.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
// One parameter, flattened for Slint's model system.
//
@@ -45,6 +45,28 @@ export struct ParamRow {
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "curve"
// A run of rows inside a group, for an operation whose parameters form a
// grid rather than a list.
//
// The colour mixer is twelve hue bands times three channels, and as a flat
// list it read as "Hue / Saturation / Luminance" twelve times over with
// nothing saying which band any row belonged to. Rust stacks the rows so
// each channel's twelve are together and marks the first of each run; this
// file names the run and lets the swatch identify the row.
//
// `facet-label` is the run's name and is empty on an ordinary parameter,
// which is every operation but the mixer.
facet-label: string,
starts-facet: bool,
// Where this row's subject sits on the hue wheel, in degrees, or -1 for a
// row whose subject is not a colour.
//
// A sentinel because a Slint struct cannot carry an optional, and -1
// rather than any in-range value because 0° is red — a real band, and the
// first one.
swatch-hue: float,
value: float,
default-value: float,
minimum: float,
@@ -288,6 +310,22 @@ component SliderTrack inherits Rectangle {
}
}
// A parameter's value, at the precision its descriptor declares.
//
// A global rather than the same ternary written into every control that shows
// one. There are two such controls now and the rule belongs to neither of
// them: precision comes from the descriptor, so a control in stops reads 1.25
// while one in whole units reads 25, and a copy that fell behind would show
// the same parameter two ways in the same panel. `SliderTrack`'s preamble is
// the longer version of this argument.
global Readout {
public pure function of(data: ParamRow) -> string {
return data.precision == 0
? Math.round(data.value) + data.unit
: (Math.round(data.value * 100) / 100) + data.unit;
}
}
// One generated parameter: a label, a readout, and the track above.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
@@ -310,11 +348,7 @@ component ParamSlider inherits Rectangle {
Rectangle { horizontal-stretch: 1; }
Value {
// Precision comes from the descriptor, so a control in stops
// reads 1.25 while one in whole units reads 25.
text: root.data.precision == 0
? Math.round(root.data.value) + root.data.unit
: (Math.round(root.data.value * 100) / 100) + root.data.unit;
text: Readout.of(root.data);
// The one readout in the panel that has moved off its default
// is what the eye is hunting for, and `modified` is the only
// thing left to say it with once hue is gone.
@@ -337,6 +371,101 @@ component ParamSlider inherits Rectangle {
}
}
// The name of a run of rows inside a group: the mixer's Hue, Saturation and
// Luminance.
//
// A `Label` rather than a third `PanelHeading`. Two levels of
// caps-with-tracking already stack above it — the panel's own `ADJUST` and the
// operation's name — and a third in the same treatment would read as their
// peer instead of as something *inside* the operation. Sentence case at the
// same size says "a part of the group above" without another size or colour.
component FacetHeading inherits Rectangle {
in property <string> title;
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-top: Theme.gap-sm;
Label { text: root.title; }
}
}
// One faceted parameter: a swatch, a track and a readout, on a single line.
//
// **The swatch is the label.** Twelve of these sit under a heading that
// already names the channel, so the only thing a row has left to say is which
// band it edits — and a 12px square says it in a fraction of the width the
// word would take. That is what makes twelve rows fit where four did: the
// mixer is thirty-six controls, and at `ParamSlider`'s two-line 46px it was
// most of a screen of scrolling with the band name absent from every row of it
// anyway.
//
// **The name is not thrown away, it moves.** It is the row's accessible label,
// so a screen reader says "Orange" where the eye reads the colour, and the
// catalogue in `labels.rs` is where the mapping is written down for anyone who
// cannot separate two squares by eye. A row identified by colour *alone*
// would be a control some photographers could not use, which is why the
// spoken name is part of the design and not an afterthought.
component SwatchSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
// The track's own height plus a hairline of air. Denser than a
// `ParamSlider` because the label line it would need is gone, not because
// the touch target shrank — `SliderTrack` still owns a full-width hit area
// and the gestures behind it (FR-UI-3).
height: Theme.touch-target / 2 + 4px;
accessible-role: slider;
accessible-label: root.data.param-label;
accessible-value: Readout.of(root.data);
accessible-value-minimum: root.data.minimum;
accessible-value-maximum: root.data.maximum;
HorizontalLayout {
padding-left: Theme.gap-sm;
spacing: Theme.gap-sm;
Swatch {
hue: root.data.swatch-hue;
// Centred against the track rather than the row, which a layout
// would do for a stretching child and cannot do for a fixed one.
y: (parent.height - self.height) / 2;
}
SliderTrack {
horizontal-stretch: 1;
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
maximum: root.data.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
Value {
text: Readout.of(root.data);
modified: root.data.value != root.data.default-value;
placeholder: root.data.value == root.data.default-value;
compact: true;
// Fixed and right-aligned: a readout sized to its own text would
// pull the track's end left and right as the number changed, and
// twelve tracks that each ended somewhere different would be
// impossible to compare down the column.
width: 30px;
horizontal-alignment: right;
}
}
}
// A slider the interface names itself, rather than one generated from a row.
//
// The straighten angle is reached through the session's own accessor, not
@@ -836,7 +965,34 @@ export component AdjustPanel inherits Rectangle {
spacing: 0px;
if entry.kind == "scalar": ParamSlider {
// A group whose parameters form a grid names each
// run once. Rust has already stacked the rows so a
// run is contiguous and marked its first row, for
// the same reason `group-head` exists: this model
// is flat and a `for` cannot nest inside a
// boundary discovered at runtime.
if entry.starts-facet: FacetHeading {
title: entry.facet-label;
}
// A row whose subject is a colour is identified by
// that colour; every other scalar keeps its name.
// The two differ only in what stands in for the
// label — the track, the gestures and the routing
// are the same underneath.
if entry.kind == "scalar" && entry.swatch-hue >= 0: SwatchSlider {
data: entry;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
entry.op-index, entry.param-index, v);
}
reset => {
root.param-reset(entry.op-index, entry.param-index);
}
}
if entry.kind == "scalar" && entry.swatch-hue < 0: ParamSlider {
data: entry;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
+39
View File
@@ -455,6 +455,45 @@ export component Value inherits Text {
vertical-alignment: center;
}
// The colour a row edits, as a small square: the label for a control whose
// subject is a hue rather than a word.
//
// **The one place hue enters the chrome, and it is not an exception to the
// palette rule so much as outside it.** The rule forbids colour used as
// decoration — an accent on a heading, a tinted border — because a saturated
// patch beside the photograph shifts how the photograph reads. This is the
// same kind of thing as the image itself: data. A row that edits the 30° band
// has to say *which* band, and no achromatic treatment can say "orange".
//
// **The core supplies degrees; everything else is decided here.** The
// operation knows its band is centred at 30° because that is the number it
// weights pixels around (ARCH §4.3a). Saturation and brightness are this
// file's to choose, and they are chosen well short of full: a fully saturated
// row of twelve squares is a paintbox sitting next to a print. Held back far
// enough to read as instrument markings, and no further, since a swatch that
// cannot be told from its neighbour has stopped identifying anything.
export component Swatch inherits Rectangle {
/// Where the subject sits on the hue wheel, in degrees.
in property <float> hue;
/// Muted from a full-strength hue, so twelve of these read as a scale
/// rather than as a palette.
in property <float> saturation: 0.55;
/// Bright enough to separate from the surface it sits on, which is dark;
/// a swatch at full value would be the brightest thing in the panel and
/// outrank the modified marker.
in property <float> brightness: 0.85;
width: Theme.swatch;
height: Theme.swatch;
horizontal-stretch: 0;
border-radius: Theme.radius-sm;
background: hsv(root.hue, root.saturation, root.brightness);
// A hairline, so a dark swatch (a deep blue at this brightness) still has
// an edge against the surface and reads as a square rather than a smudge.
border-width: 1px;
border-color: Theme.rule;
}
// Supporting text: a hint under a field, a count beside a title, an empty
// state's second line. The faintest ink, because it is there for the reader
// who stopped to look and should not catch the eye of the one who did not.