Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T
dtourolleandClaude Opus 5 2330ed25e9
Build and test / Desktop (Linux) (push) Failing after 1h4m48s
Build and test / Layer separation (push) Successful in 34s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 4s
Traceability / Requirement traces (push) Failing after 1m4s
Build and test / Android (aarch64) (push) Failing after 9m43s
Let a grouped slider be dragged, by flattening the panel that drew it
White balance, highlights and shadows, and the mixer took a press, jumped once,
and went dead under the finger. Exposure and contrast dragged perfectly — which
is what made it read as a slider bug rather than a layout one.

The panel nested. A group's head row drew the *whole* group, repeating over
`row.group-len` and indexing back into `root.rows`; every other row drew
nothing. So the inner repeater's model was read off the head row, and depended
on that row's identity. Moving any parameter in the group rewrites that row —
its own value changed, or `group-modified` flipped for its neighbours —
which re-evaluated the repeater, rebuilt its items, and destroyed the
`TouchArea` holding the live gesture. A lone parameter had no inner repeater,
so the five single-parameter operations were never affected.

Now each row draws only itself, so an update touches one control and nothing
structural. The facet heading comes off `starts-facet`, which Rust already
marks on the first row of a run, and the group heading is drawn by the row that
heads it. It also retires the old hazard of a head row building every control
in its group — thirty-six live TouchAreas behind the mixer's twelve visible
ones.

The same identity hazard reached the rows themselves through `ModelRc`, which
compares by identity rather than contents: a fresh empty model per row per call
made every row differ from itself, so `sync_rows` rewrote all of them on every
event. `points` now shares one empty model as `choices` already did.

Both are held by tests, because the failure is invisible in a still — every
value is right and the panel looks perfect. Curve rows are excluded: their
points model carries live coordinates, is rebuilt by design, and `sync_rows`
writes values through the existing model rather than swapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 07:23:01 +02:00

797 lines
32 KiB
Plaintext

// Generic adjustment controls, generated from pipeline capabilities.
//
// **Nothing here names an operation.** There is no "exposure slider" and no
// "saturation section" — the panel walks a model the core supplies and
// instantiates one control per entry, choosing the control from the
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
// the pipeline makes it appear here with no change to this file
// (FR-DEV-3c).
import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
// One parameter, flattened for Slint's model system.
//
// Flat rather than nested because Slint models do not nest cleanly; the Rust
// side flattens the capability tree into this and carries the indices needed
// to route a change back.
export struct ParamRow {
// Routing back to the core. Opaque to this file.
op-index: int,
param-index: int,
// Resolved display strings. Resolution happens in Rust against the UI's
// catalogue, because the core deals in localisation keys only.
op-label: string,
param-label: string,
// Grouping, derived in Rust from where `op-index` changes.
//
// The model is flat and Slint cannot slice one, so a group says where it
// begins and how long it is and the panel indexes back into `rows` from
// there. `group-head` is this row's group's first index — a row heads its
// group exactly when its own index equals it, which is what replaced the
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
// that the panel has sections).
group-head: int,
group-len: int,
// Any parameter of this operation differs from its default. Identical on
// every row of a group, because the heading is one of those rows and
// cannot see the others.
group-modified: bool,
// Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "enum" | "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,
maximum: float,
precision: int,
unit: string,
// Curve rows only: the point coordinates, x and y interleaved.
//
// Carried on the row rather than fetched separately because a Slint
// model row is the unit of update — splitting them would let the curve
// and its points refresh out of step. Empty for every other kind.
//
// `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it.
points: [float],
// Enum rows only: the variant names, in index order.
//
// Resolved in Rust against the UI's catalogue, like every other label
// here — the core publishes localisation keys and never a display string.
// `value` on such a row is the chosen index, which is why an enum needs no
// separate selection field.
choices: [string],
}
// The name of a group of controls, and what can be done to the group.
//
// What is left of `Section` once the collapsing is taken out: the name, the dot
// that says something inside differs from its default, and the reset. The
// sidebar is a column of instruments, and an instrument behind a lid is one the
// user has to remember to open — several of these held a single slider, so the
// lid was most of the row.
//
// The reset stays visible rather than appearing on hover, as the section's did.
// A hover-only control is one no finger can find, and this panel is now
// expected to be worked with a thumb.
component GroupHeading inherits Rectangle {
in property <string> title;
/// Something inside differs from its default.
in property <bool> modified: false;
/// Whether this group has anything to reset.
in property <bool> has-reset: true;
callback reset();
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
PanelHeading {
text: root.title;
sub: true;
horizontal-stretch: 1;
}
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.modified;
visible: root.modified;
}
Rectangle {
width: 34px;
visible: root.has-reset;
reset-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-reset;
mouse-cursor: pointer;
clicked => { root.reset(); }
}
Caption {
text: "reset";
emphasised: reset-touch.has-hover;
horizontal-alignment: right;
width: 100%;
height: 100%;
}
}
}
}
// 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.
//
// The readout is *not* editable, where the settings page's `SliderRow` pairs
// the same track with a number box. That is a considered difference rather than
// an inconsistency: this column is 280px wide and the colour mixer alone puts
// thirty-six of these in it, so a text box per row would be most of the width
// and a keyboard target nobody is aiming for. The number is still reachable —
// the track resets on double-click and right-click.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.data.param-label;
readout: 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.
modified: root.data.value != root.data.default-value;
SliderTrack {
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); }
}
}
}
// 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
// through a row index, so there is no `ParamRow` to feed it. Only the labels
// and the source of the numbers differ — the track is the same component, and
// so is every gesture it recognises.
component PlainSlider inherits Rectangle {
in property <string> label;
in property <float> value;
in property <float> default-value: 0.0;
in property <float> minimum: -1.0;
in property <float> maximum: 1.0;
in property <string> unit;
callback changed(float);
callback reset();
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value;
SliderTrack {
value: root.value;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
}
}
// **The control registry: one row in, one control out.**
//
// Slint cannot instantiate a component from a runtime string, so mapping a
// declared kind to a control is necessarily a chain of `if`s. The thing worth
// insisting on is that there is exactly *one* such chain. There were two — the
// panel draws a lone parameter bare and a group under a heading, and each
// branch wrote out its own list of kinds — so `enum` would have had to be added
// in both, and a kind added to only one would appear or vanish depending on how
// many parameters its operation happened to declare.
//
// Everything below routes back through `param-changed` by index. This component
// knows a curve point spans two parameters and a swatch names a hue band; it
// knows nothing about which operation it is drawing, which is the property that
// makes it a registry rather than a panel.
component ParamControl inherits Rectangle {
in property <ParamRow> data;
/// Curve rows only; ignored by every other kind.
in property <[float]> curve-samples;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback drag-changed(bool);
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// 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 root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
// A fixed list of alternatives. The value *is* the index, so picking
// one is an ordinary parameter change and needs no separate route.
//
// Chips rather than a dropdown for the same reason the settings page
// uses them: these lists are short, and a collapsed menu hides the
// alternatives behind a click. ARCH §4.3 names the dropdown as the
// pointer presentation of the same kind, so this is where that choice
// will be made when the modality switch lands.
if root.data.kind == "enum": Segmented {
label: root.data.param-label;
options: root.data.choices;
selected: Math.round(root.data.value);
picked(i) => {
root.param-changed(root.data.op-index, root.data.param-index, i);
}
}
if root.data.kind == "curve": CurveEditor {
points: root.data.points;
samples: root.curve-samples;
drag-changed(on) => { root.drag-changed(on); }
// A point carries two parameters, so the parameter index is the
// row's base plus the point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
root.data.op-index, root.data.param-index + point * 2, x);
root.param-changed(
root.data.op-index, root.data.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(root.data.op-index); }
}
}
}
// Crop, rotation, flips and straightening — the framing controls.
//
// **Why this is hand-built when the rest of the panel is generated.** The
// generic path renders one slider per parameter, which for framing means eight
// of them: four crop edges the user would have to type coordinates into, and a
// "Rotate" slider running 0..3. Every one of those is a worse control than the
// gesture it stands for — a crop is dragged on the photograph, and a quarter
// turn is a button. So framing is presented rather than generated, and the
// generic panel drops it (see `AdjustPanel.skip-op`).
//
// This does not weaken ARCH §4.3: nothing here reads a parameter *value* out
// of a descriptor or routes by index. It calls named session actions, which is
// what a bespoke widget for a known stage is entitled to do.
export component GeometryPanel inherits Rectangle {
in property <bool> enabled: true;
/// Whether the crop overlay is up. The button is a toggle, not an action:
/// crop mode is sustained state, and the canvas looks different while it
/// is on.
in property <bool> crop-mode: false;
in property <float> angle: 0.0;
in property <float> max-straighten: 45.0;
in property <bool> flip-h: false;
in property <bool> flip-v: false;
/// Any of crop, angle, rotation or flips differs from neutral.
in property <bool> modified: false;
callback crop-toggled(bool);
callback rotate(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
callback angle-reset();
callback reset();
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// A heading, not a collapsible.
//
// These controls are the reason the column is open; folding them away
// behind a triangle put the sidebar's own contents one tap further
// from the photograph and gave every group a lid that had to be
// learned. `GroupHeading` keeps what the section was actually for —
// naming the group, flagging that it holds an edit, and offering the
// reset — without the hiding.
GroupHeading {
title: "GEOMETRY";
modified: root.modified;
has-reset: root.modified;
reset => { root.reset(); }
}
VerticalLayout {
spacing: Theme.gap-sm;
padding-bottom: Theme.gap-sm;
// Crop first: it is the framing decision the others serve.
Button {
text: root.crop-mode ? "Done Cropping" : "Crop";
active: root.crop-mode;
enabled: root.enabled;
clicked => { root.crop-toggled(!root.crop-mode); }
}
// Rotation and flips. Icons rather than labels: four controls
// named in words would wrap the 280px column, and each of
// these shows its own result.
HorizontalLayout {
spacing: Theme.gap-sm;
IconButton {
icon: "rotate-ccw";
enabled: root.enabled;
clicked => { root.rotate(-1); }
}
IconButton {
icon: "rotate-cw";
enabled: root.enabled;
clicked => { root.rotate(1); }
}
Rectangle { horizontal-stretch: 1; }
IconButton {
icon: "flip-h";
active: root.flip-h;
enabled: root.enabled;
clicked => { root.flip-h-toggled(); }
}
IconButton {
icon: "flip-v";
active: root.flip-v;
enabled: root.enabled;
clicked => { root.flip-v-toggled(); }
}
}
PlainSlider {
label: "Straighten";
value: root.angle;
default-value: 0.0;
minimum: -root.max-straighten;
maximum: root.max-straighten;
unit: "°";
changed(v) => { root.angle-changed(v); }
reset => { root.angle-reset(); }
}
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter.
//
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
// repeat over an integer — `for n in row.group-len` — so each group's heading
// row renders its whole group by indexing back into `rows` from `group-head`,
// and every other row renders nothing.
//
// **Nothing here collapses.** Every group was a `Section` with a disclosure
// triangle until it became clear what that cost on a tablet: five of the
// pipeline's operations carry one parameter, so the lid was most of the row,
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
// a tablet where there is no modifier key to hold and no menu bar to hang the
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
// accelerator for a control that is on screen either way, which is what keeps
// the feature discoverable on both platforms.
//
// The paste button carries what would be pasted rather than the bare word.
// "Paste" alone asks the user to remember what they copied and, crucially,
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
in property <bool> enabled: true;
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply — "3 adjustments", or "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
PanelHeading { text: "SETTINGS"; }
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Copy";
enabled: root.enabled;
horizontal-stretch: 1;
clicked => { root.copy(); }
}
Button {
text: "Paste";
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: root.enabled && root.armed;
horizontal-stretch: 1;
clicked => { root.paste(); }
}
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
}
}
export component AdjustPanel inherits Rectangle {
in property <[ParamRow]> rows;
in property <bool> enabled: true;
/// The tone curve's sampled shape, evaluated in Rust by the same spline
/// the shader runs so the drawn line cannot disagree with the applied one.
in property <[float]> curve-samples;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
/// Return every parameter of one operation to its default — the reset on
/// a section's own header, beside the panel-wide one.
callback op-reset(int);
callback reset-all();
background: Theme.surface;
// A slider below has claimed the current gesture, so this panel must stop
// competing for it. See the long note on `ParamSlider`'s `claimed`: without
// this the Flickable takes any drag that drifts 8px vertically, which under
// a finger is every drag.
private property <bool> slider-dragging: false;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "ADJUST"; }
Rectangle { horizontal-stretch: 1; }
reset := TouchArea {
width: 44px;
height: 20px;
clicked => { root.reset-all(); }
Label {
text: "reset";
emphasised: reset.has-hover;
horizontal-alignment: right;
}
}
}
if !root.enabled: Caption { text: "No image"; }
if root.enabled: Flickable {
viewport-height: content.preferred-height;
interactive: !root.slider-dragging;
content := VerticalLayout {
spacing: 0px;
alignment: start;
// **The scroll gutter.**
//
// A strip down the right-hand edge that no control reaches, so
// there is always somewhere to put a thumb that means "scroll"
// and nothing else.
//
// It exists because of the arbitration in `SliderTrack`: a
// track stands the Flickable down as soon as a finger touches
// it, which is what makes dragging a slider reliable, and the
// cost is that the track can no longer be used to scroll past.
// The rows either side of a track were the only remaining
// purchase, and on a panel that is mostly tracks that came to
// aiming at a 20px band between controls. Reserving the space
// outright is the honest version of what was left to chance.
//
// Padding rather than a spacer element, and that is what makes
// it work: the strip is inside the Flickable but no child is
// laid out into it, so nothing puts a TouchArea over it. A
// press there reaches the Flickable directly, with no
// arbitration to lose.
//
// A full touch target wide (FR-UI-3), because a gutter too
// narrow to hit confidently is the problem it was added to fix.
padding-right: Theme.touch-target;
// **One row, one element, and each renders only itself.**
//
// This used to nest: a group's head row drew the *whole* group
// by repeating over `row.group-len` and indexing back into
// `root.rows` for each member, and every other row drew
// nothing. It produced the right picture and could not be
// dragged.
//
// The reason is worth writing down, because it is invisible in
// a screenshot. The inner repeater's model was `row.group-len`
// — read off the head row — so it depended on the head row's
// *identity*. Moving any parameter in the group rewrites that
// row: its own value changed, or `group-modified` flipped for
// its neighbours. Rewriting it re-evaluated the repeater, which
// rebuilt its items, which destroyed the `TouchArea` holding
// the gesture. The slider took the press, jumped once, and went
// dead under the finger for the rest of the drag.
//
// It only ever affected multi-parameter operations — white
// balance, highlights and shadows, the mixer — because a lone
// parameter had no inner repeater to rebuild. Exposure and
// contrast dragged perfectly the whole time, which is exactly
// what made it look like a slider bug rather than a layout one.
//
// Flat, each element depends only on its own `row`, so an
// update touches one control and touches nothing structural.
// It also drops the old hazard of the head row building every
// control in its group — thirty-six live TouchAreas behind the
// mixer's twelve visible ones.
for row[i] in root.rows: VerticalLayout {
spacing: 0px;
// The group's name, drawn by the row that heads it, above
// its own control rather than around the whole run.
//
// **A group of one is not a group.** Five of the pipeline's
// operations carry a single parameter — exposure, contrast,
// saturation, vibrance, brilliance — and giving each a
// heading printed the operation's name in caps directly
// above the same word as the slider's own label. Five times
// over, that is a column that reads as chrome with controls
// hidden in it. So a lone parameter is drawn bare; it loses
// the group reset, which costs nothing, since the slider
// already resets on double-click and right-click.
if row.group-head == i && row.group-len > 1: VerticalLayout {
spacing: 0px;
padding-top: Theme.gap-sm;
// A heading rather than a lid. Several of these groups
// are two sliders; hiding two sliders behind a triangle
// costs more than it saves, and a control the user
// cannot see is one they do not know the pipeline has.
GroupHeading {
title: row.op-label;
modified: row.group-modified;
// Resetting is what *this* panel's groups do; the
// heading itself has no opinion about it.
reset => { root.op-reset(row.op-index); }
}
}
// 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, which is what lets a flat loop draw
// a heading that belongs to several rows.
if row.starts-facet: FacetHeading {
title: row.facet-label;
}
ParamControl {
data: row;
curve-samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
param-changed(op, param, v) => {
root.param-changed(op, param, v);
}
param-reset(op, param) => { root.param-reset(op, param); }
curve-reset(op) => { root.curve-reset(op); }
}
}
}
}
}
Rectangle {
width: 1px;
background: Theme.rule;
}
}