Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T
dtourolle a6ea6ba83f Let the manual's scripts find a control by its name
Every scene in tools/manual aimed at window pixels written in by hand, so
a panel that gained a row moved every slider under it and the recording
went on dragging where the slider used to be. The develop column has
already moved that way (Compose now sits above Adjust), and nothing said.

A build with the `automation` feature listens on the Unix socket named
by DR_AUTOMATION and answers where an element is: by its accessible
label, the name a screen reader reads, or by its markup id for the few
things that are not controls (the canvas, the crop rectangle). It uses
Slint's element queries, which need the compiler's debug tables, so the
feature also turns those on in build.rs. It only answers questions; the
input is still xdotool's real pointer. No default build has the feature,
and one that has it listens only when the variable is set.

drive.py gains click-on, drag-on, hold-on, wait-for, wait-gone, labels
and ids. The grid's cells are now named by their file, each rating star
by its value, the sidebar's + as "New collection", and the Adjust
heading's reset as "Reset all adjustments" - controls a screen reader
could not reach before either.
2026-09-25 07:26:36 -04:00

1585 lines
73 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.
// manual: white-balance-from-the-photograph
//
// 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;
/// TRACES: FR-DEV-20
/// The vertical and horizontal keystone, and their travel either way.
in property <float> keystone-v: 0.0;
in property <float> keystone-h: 0.0;
in property <float> max-keystone: 100.0;
/// TRACES: FR-DEV-16
/// Which framing slider was moved last — 1 straighten, 2 vertical,
/// 3 horizontal — or 0 when the last control moved was an adjustment
/// (see `Adjustments.touched-op`). What `R` and the nudge keys act on.
in-out property <int> touched: 0;
/// Any of crop, angle, keystone, 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);
/// TRACES: FR-DEV-20
/// A keystone slider moved. Separate callbacks rather than one taking an
/// axis, so neither slider has to name the other's value.
callback keystone-v-changed(float);
callback keystone-h-changed(float);
/// A keystone gesture is over: refit the crop, as the end of a
/// straightening does, since the two together decide where the empty
/// corners are.
callback keystone-committed();
/// 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();
/// TRACES: FR-DEV-17
/// A crop gesture was let go; the rect it was pressed from. See
/// `CropOverlay.crop-released`.
callback crop-released(float, float, float, float);
/// TRACES: FR-DEV-17
/// How many mask layers the crop just let go took out of the frame, and
/// their names, already joined. Zero, and the notice is not drawn — the
/// common case says nothing.
///
/// Rust clears it when the history moves off that crop, so an undo takes
/// the notice away with the crop it was about.
in property <int> hidden-masks: 0;
in property <string> hidden-mask-names: "";
/// Keep the crop anyway: the notice has been read.
callback hidden-masks-kept();
}
// Crop, rotation, flips, straightening and perspective — 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.touched = 1;
Adjustments.touched-op = -1;
Framing.angle-changed(v);
}
committed(v) => { Framing.angle-committed(v); }
reset => { Framing.angle-changed(0); }
}
// TRACES: FR-DEV-20
// Perspective, under the straightening it is used with: the two
// are the corrections a building needs, and the keystone runs
// just before the angle in the frame's own map. Named for the
// lines they make parallel, as every developer names them — the
// word "keystone" is the mechanism, not the task.
PlainSlider {
label: "Vertical";
value: Framing.keystone-v;
default-value: 0.0;
minimum: -Framing.max-keystone;
maximum: Framing.max-keystone;
unit: "";
changed(v) => {
Framing.touched = 2;
Adjustments.touched-op = -1;
Framing.keystone-v-changed(v);
}
committed(v) => { Framing.keystone-committed(); }
reset => {
Framing.keystone-v-changed(0);
Framing.keystone-committed();
}
}
PlainSlider {
label: "Horizontal";
value: Framing.keystone-h;
default-value: 0.0;
minimum: -Framing.max-keystone;
maximum: Framing.max-keystone;
unit: "";
changed(v) => {
Framing.touched = 3;
Adjustments.touched-op = -1;
Framing.keystone-h-changed(v);
}
committed(v) => { Framing.keystone-committed(); }
reset => {
Framing.keystone-h-changed(0);
Framing.keystone-committed();
}
}
// 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
/// Move one scalar control a hundredth of its travel, up for a positive
/// direction — the nudge keys, on the control last moved. Rust reads the
/// range off the row, where it is.
callback param-nudged(int, 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(); }
// Named apart from the groups' "Reset" buttons below it: this
// one empties the whole panel, and a reader moving through
// the column has to be able to tell the two apart.
accessible-role: button;
accessible-label: "Reset all adjustments";
accessible-action-default => { 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) => {
Framing.touched = 0;
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.
}