Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T
dtourolle 9ebaa15099 Move copy, paste and presets into the develop top bar
They sat in the develop column under a "SETTINGS" heading, which read as
application settings, and went away with the panel toggle and in the
mask and spot modes. The strip is where undo already is for the same
reason: these act on the whole edit, not on any one panel.

The paste button still names what it would apply. The TransferPanel
component is gone; the Transfer global and its Rust wiring are
unchanged.
2026-09-22 21:37:31 -04:00

1490 lines
69 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 { Develop } from "session.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid, Check } 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,
// TRACES: FR-DEV-3
// This operation is driven by pointing at the photograph as well as by
// its sliders, so the group's heading carries the control that arms the
// canvas. Identical on every row of the group, for the reason
// `group-modified` above is: the heading is one of those rows.
//
// Set in Rust from the widget the operation asked for, never from which
// operation it is (ARCH §4.3a) — the panel still does not know that white
// balance exists, only that something here can be sampled.
group-samples: 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;
/// TRACES: FR-DEV-3
/// Whether this group can be set by pointing at the photograph.
in property <bool> has-sampler: false;
/// Whether the canvas is currently waiting for that point.
in property <bool> sampling: false;
callback reset();
/// Arm the sampler, or put it away if it is already armed.
callback sample();
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;
}
// GESTURE: Set the white balance from the photograph
// where: Develop
// touch: Press "pick" in the group's heading, then tap something
// neutral in the picture
// pointer: Press "pick", then click something neutral
// why: Sampling a neutral is the first move of the tonal pass —
// every colour judgement afterwards is measured against
// where the grey was put — and guessing at two sliders
// until a wall stops looking green is the wrong way round.
// One click is one sample and one step to undo; the sliders
// stay, because a sampled neutral is where the decision
// starts rather than where it ends.
//
// TRACES: FR-DEV-3 | NFR-A11Y-2
// The eyedropper, built exactly as the reset beside it is: a word, a
// hit target grown to a thumb, and a role so it is announced as
// something that can be pressed rather than read as a caption.
//
// A word rather than a drawn pipette, because this heading has no
// icons in it and one would be the only glyph in a column of text —
// and because "pick" says what happens next, which a pipette only
// says to somebody who already knows.
//
// It stays lit while armed. Arming changes what a click on the
// photograph *does*, and a mode with nothing saying it is on is the
// fault the develop view's own mode strip exists to prevent.
Rectangle {
width: 30px;
visible: root.has-sampler;
accessible-role: button;
accessible-label: "Set from a neutral in the photograph";
accessible-enabled: root.has-sampler;
accessible-checkable: true;
accessible-checked: root.sampling;
accessible-action-default => {
if (root.has-sampler) {
root.sample();
}
}
sample-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-sampler;
mouse-cursor: pointer;
clicked => { root.sample(); }
}
Caption {
text: "pick";
emphasised: root.sampling || sample-touch.has-hover;
horizontal-alignment: right;
width: 100%;
height: 100%;
}
}
Rectangle {
width: 34px;
visible: root.has-reset;
// A `Caption` is a `Text`, which Slint announces as text — so
// without this the reset reads as the word "reset" sitting beside
// the heading rather than as something that can be pressed.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset;
accessible-action-default => {
if (root.has-reset) {
root.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 {
// The same two strings `ControlRow` is drawing above the track.
// Drawn text and announced text are separate channels — Slint
// associates neither with the control on its own — so the label a
// sighted user reads and the label a screen reader hears come from
// one expression each, rather than the second being left empty.
label: root.data.param-label;
readout: Readout.of(root.data);
// The descriptor's declared precision, as a step: a parameter in
// whole units nudges by one and one in stops by a hundredth,
// which is the same quantum the readout is rounded to.
step: root.data.precision == 0 ? 1.0 : 0.01;
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 becomes the track'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.
//
// It used to be announced on this row, with the track below it silent. Now
// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a
// slider inside a slider, the outer one carrying the value and the inner one
// carrying the actions that can change it — so the row stands down and hands
// the same three strings to the control that owns the gesture.
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;
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;
label: root.data.param-label;
readout: Readout.of(root.data);
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);
/// The gesture is over and this is the value to keep.
///
/// **Not the same thing as `drag-changed`, which is hover.** `SliderTrack`
/// defines `engaged` as `has-hover || claimed`, because a `Flickable`
/// withholds the press and hover is the only signal that gets through in
/// time to stand it down. That makes it exactly right for what it is for —
/// telling a scrolling ancestor to let go — and exactly wrong for anything
/// that must run once when the user has finished choosing: it fires on the
/// way past without a drag at all, and then does not fire again while the
/// pointer stays on the track, however many times the value is dragged.
callback committed(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 {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
step: 0.1;
value: root.value;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.changed(v); }
committed(v) => { root.committed(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;
/// The curves this widget can plot, named. Empty, or one entry, where
/// there is nothing to choose between — see `curve-channel-picked`.
in property <[string]> curve-channels;
/// Which of `curve-channels` is on the grid.
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
/// Plot a different one of the operation's curves.
callback curve-channel-picked(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 setting that is either on or off. The value is 1 or 0, so a tick
// is an ordinary parameter change like a slider's — the row carries a
// `float` either way and the core clamps it back to a boolean.
//
// No reset affordance, and none is missing: a switch has two states
// and its default is one of them, so returning to the default is a
// click on the box. The `modified` marker still comes from the group,
// which is where the panel says something has been changed.
if root.data.kind == "bool": Check {
label: root.data.param-label;
checked: root.data.value != 0;
// The graph owns the value — an undo step and a pasted preset both
// move it without anyone touching this box — so the row draws what
// the model says and never decides for itself.
controlled: true;
toggled(on) => {
root.param-changed(
root.data.op-index, root.data.param-index, on ? 1 : 0);
}
}
// 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);
// Wrapped, because this column's width is the largest any panel
// asks for and an operation may declare as many choices as it
// likes. `film_sim`'s six film formats laid in one row came to
// 414px, which set the width of the sidebar on every screen.
columns: 3;
picked(i) => {
root.param-changed(root.data.op-index, root.data.param-index, i);
}
}
// A curve, and — where the operation offers more than one — the choice
// of which curve is on the grid.
//
// One plot rather than four stacked ones: the curves are read against
// the diagonal and against each other, which needs the grid large, and
// four grids at a quarter of the width would each be too small to
// place a point in. So the selector switches the subject of a single
// plot, and the names in it come from the core — this file does not
// know that a colour channel is what is being chosen between, only
// that the widget said it spans several named things.
if root.data.kind == "curve": VerticalLayout {
spacing: Theme.gap-sm;
if root.curve-channels.length > 1: Segmented {
// The operation's own name, which nothing else in this row
// draws: a curve row heads no group, so without this the plot
// would sit in the panel unlabelled.
label: root.data.op-label;
columns: 3;
options: root.curve-channels;
selected: root.curve-channel;
picked(i) => { root.curve-channel-picked(i); }
}
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. The base is the
// first point of the curve *on show*, so switching curve
// re-points the drag and this component still knows nothing
// about which operation — or which curve — it is drawing.
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);
}
// Resetting a curve resets the operation, which is all four of
// them — a photographer who double-clicks to start again means
// the control, not the curve that happens to be on show.
reset => { root.curve-reset(root.data.op-index); }
}
}
}
}
/// TRACES: FR-DEV-3
/// The frame: its shape, its angle, and the two ways of turning it over.
///
/// A global rather than nine properties and eight callbacks on the panel — see
/// `session.slint` for why the develop panels stopped taking their wiring
/// through the window root.
///
/// Everything here is **mirrored from the session rather than held here**, for
/// the same reason the crop rect is: Rust clamps and wraps these, so the panel
/// must show what was actually applied and not what the click asked for.
///
/// The crop overlay's own state is not here. Whether the overlay is up is
/// `Develop.cropping`, a reading of the view's mode, and the rect itself is
/// dragged on the photograph rather than set from this panel.
export global Framing {
in property <float> angle: 0.0;
/// The straighten slider's travel either way, from the descriptor.
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 — what
/// lights the section's dot and enables its reset. Distinct from the crop
/// rect being non-full: a rotation or a flip is an edit with the crop
/// still full.
in property <bool> modified: false;
/// TRACES: FR-DEV-3
/// The ratios the crop may be locked to, and which one is chosen.
///
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
/// and the labels come off it, so a ratio added there appears without this
/// file changing.
in property <[string]> aspects;
in property <int> aspect: 0;
/// Whether the chosen ratio is standing on its short edge.
in property <bool> portrait: false;
/// Whether the chosen ratio has a second orientation at all — `Free` and
/// the square do not, and the switch says so rather than vanishing.
in property <bool> aspect-turnable: false;
/// Quarter turns, positive clockwise.
callback rotate(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
/// TRACES: FR-DEV-3
/// The straightening gesture is over and this is the angle to keep.
///
/// What the auto-crop hangs off — see `DevelopSession::auto_crop_to_angle`
/// for why it must be the end of the drag and not every frame of it, and
/// `PlainSlider::committed` for why it must not be `drag-changed`.
callback angle-committed(float);
/// Lock the crop to one of `aspects`, by index.
callback aspect-picked(int);
/// Stand the chosen ratio on its other edge.
callback portrait-toggled();
/// Crop, angle, rotation and flips back to neutral, leaving colour alone.
callback reset();
}
// 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 ComposePanel inherits Rectangle {
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: "COMPOSE";
modified: Framing.modified;
has-reset: Framing.modified;
reset => { Framing.reset(); }
}
VerticalLayout {
spacing: Theme.gap-sm;
padding-bottom: Theme.gap-sm;
// No crop button. Crop is one value of the view mode now, entered
// and left from the strip pinned at the top of this column — a
// second control that entered the same mode would be a second
// thing that has to agree about which mode the view is in, and
// the whole point of the enum is that there is one answer.
// 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.
//
// The words are still written, once each, as `label` — the width
// argument is about the column, and a screen reader has no column.
// The two flips also declare themselves checkable, which
// `IconButton` cannot do on its own: `active` says a toggle is on
// and says nothing at all about whether an inactive control is a
// toggle that is off, so only these call sites know that the two
// rotations are not toggles and these two are.
HorizontalLayout {
spacing: Theme.gap-sm;
IconButton {
icon: "rotate-ccw";
label: "Rotate left";
enabled: Develop.enabled;
clicked => { Framing.rotate(-1); }
}
IconButton {
icon: "rotate-cw";
label: "Rotate right";
enabled: Develop.enabled;
clicked => { Framing.rotate(1); }
}
Rectangle { horizontal-stretch: 1; }
IconButton {
icon: "flip-h";
label: "Flip horizontally";
active: Framing.flip-h;
accessible-checkable: true;
accessible-checked: Framing.flip-h;
enabled: Develop.enabled;
clicked => { Framing.flip-h-toggled(); }
}
IconButton {
icon: "flip-v";
label: "Flip vertically";
active: Framing.flip-v;
accessible-checkable: true;
accessible-checked: Framing.flip-v;
enabled: Develop.enabled;
clicked => { Framing.flip-v-toggled(); }
}
}
PlainSlider {
label: "Straighten";
value: Framing.angle;
default-value: 0.0;
minimum: -Framing.max-straighten;
maximum: Framing.max-straighten;
unit: "°";
changed(v) => { Framing.angle-changed(v); }
committed(v) => { Framing.angle-committed(v); }
reset => { Framing.angle-changed(0); }
}
// TRACES: FR-DEV-3
// The ratio the crop is held to.
//
// **A grid rather than a row.** Six chips laid side by side need
// over 400px, and this column's width is the largest any panel
// declares — so a single row would widen every other panel in the
// application to fit a control that is on screen only while
// cropping. Three columns is what the column already has room for.
//
// The chips are drawn from the model rather than written out, so
// the offered set lives in one place: `CropAspect::CHOICES`. Their
// positions are computed from the index for the same reason — a
// seventh ratio wraps onto a third row by itself.
// **Three individually-conditional children, not one `if` around a
// nested layout.** A nested `VerticalLayout` under a condition
// under-reports its height here, and the panels below it get drawn
// on top of one another — invisible in this file and obvious on
// screen. `app.slint` and `masks.slint` both carry the same note
// for the same reason.
// **Only while the crop overlay is up.** Shown at all times these
// would be a control for a gesture that is not on screen; hidden
// behind a lid they would be a feature nobody finds.
if Develop.cropping: Label { text: "Ratio"; body: true; }
// The same wrapping grid the generated enum rows use, for the
// same reason: six chips in a row would be 414px of chips setting
// the width of a column that is meant to be 280.
if Develop.cropping: ChipGrid {
options: Framing.aspects;
selected: Framing.aspect;
enabled: Develop.enabled;
picked(i) => { Framing.aspect-picked(i); }
}
// One chip, lit when the ratio is standing on its short edge,
// rather than a Landscape/Portrait pair: the second chip in such a
// pair says nothing the first does not, and this column is narrow.
//
// Disabled rather than hidden where the ratio has no second
// orientation, so the panel does not change height as the chips
// above it are tried.
//
// In a container of its own so it takes one chip's width and sits
// under the first column: a `ChoiceChip` placed straight into the
// `VerticalLayout` is stretched across the whole panel, which reads
// as a button for the section rather than as one more chip.
if Develop.cropping: Rectangle {
height: Theme.control-height;
ChoiceChip {
x: 0;
width: 88px;
height: 100%;
label: "Portrait";
selected: Framing.portrait;
enabled: Develop.enabled && Framing.aspect-turnable;
clicked => { Framing.portrait-toggled(); }
}
}
}
}
}
/// TRACES: FR-DEV-6
/// The settings clipboard, as the develop column sees it.
///
/// A global rather than three properties and three callbacks on the panel —
/// `session.slint` gives the argument.
///
/// The clipboard itself is a **window-lifetime** thing and not a view's: a
/// copy is taken in develop and may be pasted onto a selection back in the
/// library grid, and the grid's own paste stays on the window root where the
/// grid can reach it. What is here is the develop half — the two buttons and
/// what they would do.
export global Transfer {
/// 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, at the current scope — "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();
/// TRACES: FR-DEV-6
/// Open the named-preset sheet over the open photograph. Beside copy and
/// paste because it is the same thought given a name — this edit, kept.
///
/// Handled in Rust rather than by setting the sheet's properties here, as
/// the panel's instantiation used to: a global cannot bind the window's
/// state, and the handler has to say what saving would capture anyway.
/// The grid opens the same sheet with its own count, from `app.slint`.
callback open-presets();
}
/// TRACES: FR-DEV-3
/// The generated controls: what they show, what they are pointed at, and what
/// pressing one means.
///
/// A global rather than eleven properties and eight callbacks spread over two
/// panels — `session.slint` gives the argument. `GroupStrip` and `AdjustPanel`
/// share it because they are one control between them: the strip says which
/// slice of `rows` the panel is drawing, and a strip that filtered a different
/// panel from the one beside it would be a control for nothing.
///
/// **This names no operation and no parameter.** Everything here is a model
/// the core supplied or an index back into it (ARCH §4.3a, FR-DEV-3a).
export global Adjustments {
in property <[ParamRow]> rows;
/// What these controls are pointed at — the whole photograph, or one mask
/// layer by name.
///
/// Supplied already resolved: whether a layer is selected and what it is
/// called are session facts, and deriving them in the panel would need
/// that file to reason about the mask stack.
in property <string> scope: "ADJUST";
/// The groups the strip offers, already resolved, and which is chosen.
/// -1 is "everything".
in property <[string]> tabs;
in property <int> active-tab: -1;
callback tab-picked(int);
/// TRACES: FR-UI-1 | FR-UI-7
/// Whether the groups are chosen from the tool rail instead of from the
/// strip above the column.
///
/// Owned by Rust, which composes it from the input modality and the
/// photographer's override — see `ToolRail::groups-in-rail` for why the
/// axis is input rather than platform or width.
///
/// The two controls are mutually exclusive by construction: this shows one
/// and hides the other, so there is never a moment with two ways to answer
/// the same question, and no state to keep in step between them.
in property <bool> groups-in-rail: false;
/// 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;
/// The curves the tone curve widget can plot, named by the core. Fewer
/// than two of them means there is nothing to choose and no selector.
in property <[string]> curve-channels;
/// Which of them `curve-samples` and the row's points describe.
in property <int> curve-channel;
callback param-changed(int, int, float);
callback param-reset(int, int);
/// TRACES: FR-DEV-16
/// The control last moved, so a key can put it back.
///
/// The generated rows have no focus: they are a model the repeater
/// rebuilds, and a focus ring for them would be a larger change than the
/// binding wants. What a photographer means by "reset that" from the
/// keyboard is the slider they just dragged too far, and that is a thing
/// the panel can remember without any of them being focused. -1 is none,
/// which is where every photograph starts — see `lib.rs`, which clears
/// these on open so the key cannot reach back into the previous edit.
in-out property <int> touched-op: -1;
in-out property <int> touched-param: -1;
/// Every parameter of one operation back to its default. A section's reset
/// and a curve's reset are the same action, so they share the one callback
/// rather than duplicating a handler that would have to be kept in step
/// with it.
callback curve-reset(int);
/// Plot a different one of the curve's curves.
callback curve-channel-picked(int);
callback reset-all();
// TRACES: FR-DEV-3
// The on-canvas sampler: which group has armed it.
//
// The op index rather than a bool, because arming is a question about a
// *group* — two operations could ask for a sampler and only one of them
// can be waiting for the click. -1 is none armed, the sentinel this file
// already uses for "no swatch" and "everything" and for the same reason:
// a Slint struct or property cannot carry an optional.
in-out property <int> sampling-op: -1;
/// The heading's picker was pressed for the operation at this index.
///
/// A function rather than a callback into Rust, and that is deliberate:
/// arming changes what a click on the photograph does and nothing about
/// the edit, so there is nothing for the session to hear about until a
/// point is actually picked. It lived in `app.slint`'s instantiation of
/// the panel before, which is exactly the sort of behaviour a second
/// instantiation would have had to copy.
public function arm-sampler(op: int) {
self.sampling-op = self.sampling-op == op ? -1 : op;
}
// TRACES: FR-DEV-3f
// The film stock, which is not a parameter and so is not a `ParamRow`.
//
// A bespoke control for the same reason the mask panel is one: a stock is
// a choice of *material*, named by an id, not a number on a slider. The
// generic rows keep their rule — they still name no operation — and the
// film's own exposure sliders arrive through them like any other.
//
// Names are supplied already resolved, and the list is whatever profiles
// are installed: this file does not know what a Kodachrome is.
/// TRACES: FR-DEV-3f
/// Whether the stock picker belongs in the group on screen.
///
/// Decided in Rust from what the operation says it is about, because a
/// stock is not a parameter and so is not filtered by the row builder that
/// hides everything else. This file still names no group.
in property <bool> film-in-group: true;
in property <[string]> film-stocks;
/// Index into `film-stocks`. Zero is the first entry, which Rust makes
/// "no film" — so a fresh photograph selects it without a sentinel.
in property <int> film-selected: 0;
/// Whether the chosen stock is a negative with a paper to print on. False
/// for a reversal stock, which is the picture already, and for no film.
in property <bool> film-can-print: false;
/// Print it, rather than view the negative as scanned.
in property <bool> film-print: false;
callback film-picked(int);
callback film-print-toggled(bool);
}
/// The group strip: which kind of adjustment the column is showing.
///
/// One kind of entry, which is the change. This was `ModeStrip` and carried
/// two: the canvas tools — crop, local, repair — as filled chips, and the
/// adjustment groups as underlined words, in a single row that asked the eye
/// to tell a mode from a filter by the shape of its highlight. The tools are
/// in `ToolRail` now (`toolrail.slint`, which carries the reasoning), and what
/// is left here is one row of one thing.
///
/// A group is something you are *looking at*, not something you are *in*, and
/// that distinction is now made by the two controls being in different places
/// rather than by two treatments of one control. It is also why the two stay
/// independent: picking a group while a tool is held filters the selected
/// layer's chain and does not put the tool down, so "Light" means the same
/// thing wherever it is pressed — the alternative, where a group press
/// silently dropped the scope, would be the §1.1 fault from the other end.
///
/// **Pinned above the scrolling column, not inside a panel.** It began inside
/// `AdjustPanel`, which put it below five other panels and off the bottom of a
/// tablet screen — present, working, and unreachable without scrolling past
/// everything it was meant to help you avoid scrolling past. A control that
/// answers "where is everything else" cannot itself be somewhere else.
///
/// **This file names no group.** The strings arrive already resolved from
/// whatever the operations declared themselves to be about, so a new operation
/// joins a group without an edit here (FR-DEV-3a). With the tools gone there is
/// no longer any hand-written word in this row at all, save "All".
export component GroupStrip inherits Rectangle {
/// Whether the strip has anything to offer and is the control that offers
/// it: an open photograph, with the groups not already in the rail.
///
/// Derived here rather than bound at the instantiation, so a second
/// composition of this column cannot get the rule slightly different.
property <bool> live: Develop.enabled && !Adjustments.groups-in-rail;
background: Theme.surface;
height: root.live ? layout.preferred-height : 0px;
visible: root.live;
// Scrolls rather than overflowing, exactly as the develop status strip
// does and for the same reason: a `HorizontalLayout` given less width than
// its children need does not shrink them, it runs off the end.
//
// This is now the *only* answer, and it is the right one here. The column
// this sits in is a mandated width (`panel-width` in `style.yaml`) while
// the row's contents are one word per group the operation set declares —
// generated, and so unbounded in principle. It used to publish a
// `content-width` that the column sized itself from, which made a rich
// operation set quietly take width from the photograph. A row that pans is
// the price of a photograph that does not move.
Flickable {
width: 100%;
height: 100%;
viewport-height: self.height;
viewport-width: max(self.width, layout.preferred-width);
layout := HorizontalLayout {
width: parent.viewport-width;
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
// **Where a canvas tool goes: not here.** A mode that arms a gesture
// on the photograph is a row in `ToolRail`'s table, one file over.
//
// Three of them used to sit at the head of this row as filled chips,
// with a one-pixel divider after them. Both are gone, and nothing has
// replaced them — no heading, no lead-in. A run of words with one
// underlined is a tab bar, which is exactly what this is, and it needs
// saying only while there is something else in the row to be told
// apart from. The width that buys back is the point: this row pans
// when the operation set is rich, so anything permanently occupying
// its left-hand end is paid for by every group after the third.
all := TouchArea {
width: 34px;
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { Adjustments.tab-picked(-1); }
Label {
text: "All";
emphasised: Adjustments.active-tab == -1 || all.has-hover;
vertical-alignment: center;
}
Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: Adjustments.active-tab == -1 ? Theme.ink : transparent;
}
}
for tab[i] in Adjustments.tabs: tab-area := TouchArea {
width: name.preferred-width + 10px;
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { Adjustments.tab-picked(i); }
name := Label {
text: tab;
emphasised: Adjustments.active-tab == i || tab-area.has-hover;
vertical-alignment: center;
horizontal-alignment: center;
}
// Underlined rather than filled: the accent is the mode chips'
// now, and spending it on "which group" as well would blunt both.
Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: Adjustments.active-tab == i ? Theme.ink : transparent;
}
}
}
}
}
// 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.
export component AdjustPanel inherits Rectangle {
/// Whether the stock list is open. Pure interface state: it changes no
/// parameter and the core never hears about it.
///
/// Private to the panel rather than in `Adjustments`, and deliberately: it
/// is a lid, and two drawings of this panel may honestly have their lids
/// in different positions.
private property <bool> film-expanded: false;
/// One stock. Shorter than a touch target on purpose — see the row below.
private property <length> film-row-height: 32px;
/// How much column the open list may take before it scrolls instead.
private property <length> film-list-max: 320px;
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.
/// True while a track has claimed a gesture.
///
/// `out` rather than private because the Flickable that must stand down
/// for it is no longer in this component: the whole develop column
/// scrolls as one, so the panel reports the drag and the column obeys it.
out property <bool> slider-dragging: false;
// **The sliders are what `panel-width` was chosen against.** No panel gets
// a say in the column's width any more — it is one number in `style.yaml`
// — but this is the panel that number has to be right for. Each row is a
// label, a value and a track, and a track squeezed below the width its
// handle needs is a control that cannot be set accurately, which is the
// whole job. If the column is ever narrowed, narrow it against this.
layout := VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading {
text: Adjustments.scope;
// Elided rather than wrapped. A layer's name is the user's and
// can be any length; a heading that wrapped would change the
// panel's height as the selection moved, and an unwrapped one
// would set the 280px column's minimum width from it.
overflow: elide;
horizontal-stretch: 1;
}
// No spacer: the heading takes the slack itself, so a long layer
// name elides against the reset rather than pushing it off the
// 280px column.
reset := TouchArea {
width: 44px;
height: 20px;
clicked => { Adjustments.reset-all(); }
Label {
text: "reset";
emphasised: reset.has-hover;
horizontal-alignment: right;
}
}
}
if !Develop.enabled: Caption { text: "No image"; }
// TRACES: FR-DEV-3f
// Above the rows, because it decides what they mean: the exposure
// slider below is a slider on *this stock's* characteristic curve, and
// the same number is a different picture on a different film.
if Develop.enabled && Adjustments.film-in-group && Adjustments.film-stocks.length > 1: VerticalLayout {
spacing: Theme.gap-sm;
// **Collapsed to one row, and opened only to change it.**
//
// The panel above deliberately took the lids off its *sliders* —
// an instrument behind a lid is one you forget to open. A stock
// list is the other kind of control: two dozen entries, chosen
// once, and then only its answer matters. Left open it was a
// thousand pixels of column standing between the photographer and
// every slider below it.
//
// No PopupWindow, no scroller of its own: the develop column
// scrolls as one, so the list is simply taller content while it is
// open, and there is no second gesture to lose a drag to.
summary := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.film-expanded = !root.film-expanded; }
Rectangle {
border-radius: Theme.radius;
border-width: 1px;
border-color: Theme.rule;
background: summary.pressed ? Theme.pressed
: (summary.has-hover ? Theme.hover : transparent);
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
Label {
text: "Film";
vertical-alignment: center;
}
Value {
// The chosen stock's own name, elided rather than
// wrapped: it is a product name of any length and
// this column is 280px.
text: Adjustments.film-stocks[Adjustments.film-selected];
overflow: elide;
horizontal-alignment: right;
horizontal-stretch: 1;
vertical-alignment: center;
}
Label {
text: root.film-expanded ? "\u{25be}" : "\u{25b8}";
vertical-alignment: center;
}
}
}
}
// **Its own scroller, bounded.** Two dozen stocks laid out at full
// height made the list the tallest thing in the column, so the
// develop panel scrolled as one and reaching Velvia dragged every
// slider below it off the screen — the answer to a question that
// is asked once pushing aside the controls used constantly.
//
// The viewport is counted rather than measured, for the reason the
// tool rail records: a viewport that asks a layout how tall it
// wants to be, while the layout takes its height from the viewport,
// is a cycle Slint settles by handing back the height it was given
// — and the content is then clipped in silence instead of
// scrolling. Every row here is `film-row-height` by construction,
// so multiplying is exact.
if root.film-expanded: Flickable {
height: min(Adjustments.film-stocks.length * root.film-row-height, root.film-list-max);
viewport-width: self.width;
viewport-height: Adjustments.film-stocks.length * root.film-row-height;
list := VerticalLayout {
height: parent.viewport-height;
spacing: 0px;
alignment: start;
for stock[i] in Adjustments.film-stocks: film-row := TouchArea {
// Shorter than a touch target, which is a deliberate
// exception and the only one here: a list row's neighbours
// are other rows, so growing the hit area the way a chip
// does would put each row's target over the one above it
// and hand taps to the wrong film.
height: 32px;
mouse-cursor: pointer;
clicked => {
Adjustments.film-picked(i);
// Closed on choosing. The answer is now in the summary
// row, and leaving two dozen entries open after the
// question has been answered is the behaviour this
// control was collapsed to avoid.
root.film-expanded = false;
}
Rectangle {
border-radius: Theme.radius;
background: i == Adjustments.film-selected ? Theme.active
: (film-row.pressed ? Theme.pressed
: (film-row.has-hover ? Theme.hover : transparent));
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap-sm;
alignment: start;
Label {
text: stock;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
}
// Only for a negative. A reversal stock has no paper — it is the
// photograph as it comes — so offering the choice would be
// offering something that cannot happen.
if Adjustments.film-can-print: HorizontalLayout {
spacing: Theme.gap-sm;
alignment: start;
print-toggle := TouchArea {
width: 120px;
height: Theme.touch-target;
clicked => { Adjustments.film-print-toggled(!Adjustments.film-print); }
Label {
text: Adjustments.film-print ? "Printed" : "Scanned negative";
emphasised: print-toggle.has-hover;
}
}
}
}
// No Flickable here any more. The histogram, the capture metadata and
// the geometry controls sat *above* this one and could not be scrolled
// away, so on a 280px column in portrait they ate the height the
// sliders needed and the instrument the sliders are judged against was
// unreachable. The column scrolls as one now, and a scroller inside a
// scroller would give every drag a third thing to be lost to.
if Develop.enabled: VerticalLayout {
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
// `Adjustments.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 Adjustments.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;
// TRACES: FR-DEV-3
// And, where the operation asked for one, the
// control that arms the canvas. The heading knows
// only that it has a sampler; which group is
// waiting is the panel's business, because only
// the panel can see the others.
has-sampler: row.group-samples;
sampling: Adjustments.sampling-op == row.op-index;
sample => { Adjustments.arm-sampler(row.op-index); }
// Resetting is what *this* panel's groups do; the
// heading itself has no opinion about it.
reset => { Adjustments.curve-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: Adjustments.curve-samples;
curve-channels: Adjustments.curve-channels;
curve-channel: Adjustments.curve-channel;
drag-changed(on) => { root.slider-dragging = on; }
param-changed(op, param, v) => {
Adjustments.touched-op = op;
Adjustments.touched-param = param;
Adjustments.param-changed(op, param, v);
}
param-reset(op, param) => { Adjustments.param-reset(op, param); }
curve-reset(op) => { Adjustments.curve-reset(op); }
curve-channel-picked(i) => { Adjustments.curve-channel-picked(i); }
}
}
}
}
}
// No seam of its own.
//
// There was one — a 1px `rule` rectangle — and being a sized child of a
// plain Rectangle with no position, Slint *centred* it: a hairline drawn
// straight down the middle of the panel, through every slider in it. The
// column that hosts this panel already draws the seam between itself and
// the photograph, so the fix is one rule in one place rather than two that
// were never both wanted.
}