`Attribute::Geometry` becomes `Attribute::Compose`, and `film_sim` moves from `[tone, colour]` to `[effect]`. Two categories were doing the wrong job. "Geometry" describes what crop, straighten and the quarter turns do to coordinates — but it describes lens distortion correction exactly as well, and that is not a compositional choice at all. Naming the attribute for the photographer's decision is what separates it from `Optics`: one is what the lens did, the other is what they chose. The maths the two have in common is not the thing worth filing them under. A film stock declared both `tone` and `colour`, so "Kodachrome" appeared in the Light group beside exposure and again in Colour beside white balance — two places, neither of which is where anyone looks for it. It is neither: `Effect` is defined in this same file as "applied rather than corrected — a look, not a fix", which is what a stock is. That it moves tone and colour is true of every look, and is not what the attribute is for. `from_name` still accepts "geometry" on the way in. That string is persisted in `develop.copy_attributes`, and an entry it fails to parse is not an error — `presets::scope_for` logs it and drops it — so without the alias an existing settings file would have quietly narrowed what a paste carries. `name` writes the current spelling, so the file migrates itself the first time it is saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1320 lines
59 KiB
Plaintext
1320 lines
59 KiB
Plaintext
// Generic adjustment controls, generated from pipeline capabilities.
|
|
//
|
|
// **Nothing here names an operation.** There is no "exposure slider" and no
|
|
// "saturation section" — the panel walks a model the core supplies and
|
|
// instantiates one control per entry, choosing the control from the
|
|
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
|
|
// the pipeline makes it appear here with no change to this file
|
|
// (FR-DEV-3c).
|
|
|
|
import { Theme } from "theme.slint";
|
|
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
|
|
import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip, ChipGrid } from "controls.slint";
|
|
|
|
// One parameter, flattened for Slint's model system.
|
|
//
|
|
// Flat rather than nested because Slint models do not nest cleanly; the Rust
|
|
// side flattens the capability tree into this and carries the indices needed
|
|
// to route a change back.
|
|
export struct ParamRow {
|
|
// Routing back to the core. Opaque to this file.
|
|
op-index: int,
|
|
param-index: int,
|
|
|
|
// Resolved display strings. Resolution happens in Rust against the UI's
|
|
// catalogue, because the core deals in localisation keys only.
|
|
op-label: string,
|
|
param-label: string,
|
|
|
|
// Grouping, derived in Rust from where `op-index` changes.
|
|
//
|
|
// The model is flat and Slint cannot slice one, so a group says where it
|
|
// begins and how long it is and the panel indexes back into `rows` from
|
|
// there. `group-head` is this row's group's first index — a row heads its
|
|
// group exactly when its own index equals it, which is what replaced the
|
|
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
|
|
// that the panel has sections).
|
|
group-head: int,
|
|
group-len: int,
|
|
|
|
// Any parameter of this operation differs from its default. Identical on
|
|
// every row of a group, because the heading is one of those rows and
|
|
// cannot see the others.
|
|
group-modified: bool,
|
|
|
|
// Which control to build. Mirrors ParamKind, plus the widget kinds an
|
|
// operation can request through its presentation.
|
|
kind: string, // "scalar" | "bool" | "enum" | "curve"
|
|
|
|
// A run of rows inside a group, for an operation whose parameters form a
|
|
// grid rather than a list.
|
|
//
|
|
// The colour mixer is twelve hue bands times three channels, and as a flat
|
|
// list it read as "Hue / Saturation / Luminance" twelve times over with
|
|
// nothing saying which band any row belonged to. Rust stacks the rows so
|
|
// each channel's twelve are together and marks the first of each run; this
|
|
// file names the run and lets the swatch identify the row.
|
|
//
|
|
// `facet-label` is the run's name and is empty on an ordinary parameter,
|
|
// which is every operation but the mixer.
|
|
facet-label: string,
|
|
starts-facet: bool,
|
|
|
|
// Where this row's subject sits on the hue wheel, in degrees, or -1 for a
|
|
// row whose subject is not a colour.
|
|
//
|
|
// A sentinel because a Slint struct cannot carry an optional, and -1
|
|
// rather than any in-range value because 0° is red — a real band, and the
|
|
// first one.
|
|
swatch-hue: float,
|
|
|
|
value: float,
|
|
default-value: float,
|
|
minimum: float,
|
|
maximum: float,
|
|
precision: int,
|
|
unit: string,
|
|
|
|
// Curve rows only: the point coordinates, x and y interleaved.
|
|
//
|
|
// Carried on the row rather than fetched separately because a Slint
|
|
// model row is the unit of update — splitting them would let the curve
|
|
// and its points refresh out of step. Empty for every other kind.
|
|
//
|
|
// `param-index` on a curve row is the index of the *first* point
|
|
// parameter, so a drag routes back by offsetting from it.
|
|
points: [float],
|
|
|
|
// Enum rows only: the variant names, in index order.
|
|
//
|
|
// Resolved in Rust against the UI's catalogue, like every other label
|
|
// here — the core publishes localisation keys and never a display string.
|
|
// `value` on such a row is the chosen index, which is why an enum needs no
|
|
// separate selection field.
|
|
choices: [string],
|
|
}
|
|
|
|
// The name of a group of controls, and what can be done to the group.
|
|
//
|
|
// What is left of `Section` once the collapsing is taken out: the name, the dot
|
|
// that says something inside differs from its default, and the reset. The
|
|
// sidebar is a column of instruments, and an instrument behind a lid is one the
|
|
// user has to remember to open — several of these held a single slider, so the
|
|
// lid was most of the row.
|
|
//
|
|
// The reset stays visible rather than appearing on hover, as the section's did.
|
|
// A hover-only control is one no finger can find, and this panel is now
|
|
// expected to be worked with a thumb.
|
|
component GroupHeading inherits Rectangle {
|
|
in property <string> title;
|
|
/// Something inside differs from its default.
|
|
in property <bool> modified: false;
|
|
/// Whether this group has anything to reset.
|
|
in property <bool> has-reset: true;
|
|
|
|
callback reset();
|
|
|
|
height: Theme.control-height;
|
|
|
|
HorizontalLayout {
|
|
padding-left: Theme.gap-sm;
|
|
padding-right: Theme.gap-sm;
|
|
spacing: Theme.gap-sm;
|
|
|
|
PanelHeading {
|
|
text: root.title;
|
|
sub: true;
|
|
horizontal-stretch: 1;
|
|
}
|
|
|
|
Rectangle {
|
|
width: 6px;
|
|
height: 6px;
|
|
y: (parent.height - self.height) / 2;
|
|
border-radius: 3px;
|
|
background: Theme.modified;
|
|
visible: root.modified;
|
|
}
|
|
|
|
Rectangle {
|
|
width: 34px;
|
|
visible: root.has-reset;
|
|
|
|
// 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 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); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
in property <bool> enabled: true;
|
|
in property <float> angle: 0.0;
|
|
in property <float> max-straighten: 45.0;
|
|
in property <bool> flip-h: false;
|
|
in property <bool> flip-v: false;
|
|
/// Any of crop, angle, rotation or flips differs from neutral.
|
|
in property <bool> modified: false;
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The crop overlay is up, so the ratio lock is a control for a gesture
|
|
/// that is currently on screen.
|
|
in property <bool> cropping: false;
|
|
/// The ratios on offer, named by Rust — see `CropAspect::CHOICES`.
|
|
in property <[string]> aspects;
|
|
in property <int> aspect: 0;
|
|
in property <bool> portrait: false;
|
|
/// Whether the chosen ratio has a second orientation. `Free` and the
|
|
/// square do not.
|
|
in property <bool> aspect-turnable: false;
|
|
|
|
callback rotate(int);
|
|
callback flip-h-toggled();
|
|
callback flip-v-toggled();
|
|
callback angle-changed(float);
|
|
/// 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);
|
|
callback angle-reset();
|
|
callback aspect-picked(int);
|
|
callback portrait-toggled();
|
|
callback reset();
|
|
|
|
height: layout.preferred-height;
|
|
|
|
layout := VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// A heading, not a collapsible.
|
|
//
|
|
// These controls are the reason the column is open; folding them away
|
|
// behind a triangle put the sidebar's own contents one tap further
|
|
// from the photograph and gave every group a lid that had to be
|
|
// learned. `GroupHeading` keeps what the section was actually for —
|
|
// naming the group, flagging that it holds an edit, and offering the
|
|
// reset — without the hiding.
|
|
GroupHeading {
|
|
title: "COMPOSE";
|
|
modified: root.modified;
|
|
has-reset: root.modified;
|
|
reset => { root.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: root.enabled;
|
|
clicked => { root.rotate(-1); }
|
|
}
|
|
IconButton {
|
|
icon: "rotate-cw";
|
|
label: "Rotate right";
|
|
enabled: root.enabled;
|
|
clicked => { root.rotate(1); }
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
IconButton {
|
|
icon: "flip-h";
|
|
label: "Flip horizontally";
|
|
active: root.flip-h;
|
|
accessible-checkable: true;
|
|
accessible-checked: root.flip-h;
|
|
enabled: root.enabled;
|
|
clicked => { root.flip-h-toggled(); }
|
|
}
|
|
IconButton {
|
|
icon: "flip-v";
|
|
label: "Flip vertically";
|
|
active: root.flip-v;
|
|
accessible-checkable: true;
|
|
accessible-checked: root.flip-v;
|
|
enabled: root.enabled;
|
|
clicked => { root.flip-v-toggled(); }
|
|
}
|
|
}
|
|
|
|
PlainSlider {
|
|
label: "Straighten";
|
|
value: root.angle;
|
|
default-value: 0.0;
|
|
minimum: -root.max-straighten;
|
|
maximum: root.max-straighten;
|
|
unit: "°";
|
|
changed(v) => { root.angle-changed(v); }
|
|
committed(v) => { root.angle-committed(v); }
|
|
reset => { root.angle-reset(); }
|
|
}
|
|
|
|
// 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.
|
|
if root.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 root.cropping: ChipGrid {
|
|
options: root.aspects;
|
|
selected: root.aspect;
|
|
enabled: root.enabled;
|
|
picked(i) => { root.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 root.cropping: Rectangle {
|
|
height: Theme.control-height;
|
|
|
|
ChoiceChip {
|
|
x: 0;
|
|
width: 88px;
|
|
height: 100%;
|
|
label: "Portrait";
|
|
selected: root.portrait;
|
|
enabled: root.enabled && root.aspect-turnable;
|
|
clicked => { root.portrait-toggled(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The panel: a heading per multi-parameter operation, a control per parameter.
|
|
//
|
|
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
|
|
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
|
|
// repeat over an integer — `for n in row.group-len` — so each group's heading
|
|
// row renders its whole group by indexing back into `rows` from `group-head`,
|
|
// and every other row renders nothing.
|
|
//
|
|
// **Nothing here collapses.** Every group was a `Section` with a disclosure
|
|
// triangle until it became clear what that cost on a tablet: five of the
|
|
// pipeline's operations carry one parameter, so the lid was most of the row,
|
|
// and a control behind a lid is one the user does not know the pipeline has.
|
|
// The column is closed as a whole from the status strip instead, which is the
|
|
// control that was actually wanted.
|
|
// TRACES: FR-DEV-6
|
|
// Copying this photograph's settings, and pasting settings onto it.
|
|
//
|
|
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
|
|
// a tablet where there is no modifier key to hold and no menu bar to hang the
|
|
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
|
|
// accelerator for a control that is on screen either way, which is what keeps
|
|
// the feature discoverable on both platforms.
|
|
//
|
|
// The paste button carries what would be pasted rather than the bare word.
|
|
// "Paste" alone asks the user to remember what they copied and, crucially,
|
|
// whether the crop is coming with it — a question the label answers by
|
|
// naming the count the *current* scope would apply.
|
|
export component TransferPanel inherits VerticalLayout {
|
|
in property <bool> enabled: true;
|
|
/// Whether anything has been copied yet. Distinct from the clipboard
|
|
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
|
|
/// since it clears the target.
|
|
in property <bool> armed: false;
|
|
/// What a paste would apply — "3 adjustments", or "Neutral".
|
|
in property <string> summary;
|
|
/// Whether the clipboard holds framing the current scope is dropping.
|
|
/// Only then is it worth saying anything about the crop.
|
|
in property <bool> framing-withheld: false;
|
|
|
|
callback copy();
|
|
callback paste();
|
|
/// TRACES: FR-DEV-6
|
|
/// Open the named-preset sheet. Beside copy and paste because it is the
|
|
/// same thought given a name — this edit, kept.
|
|
callback open-presets();
|
|
|
|
padding: Theme.gap;
|
|
spacing: Theme.gap-sm;
|
|
|
|
HorizontalLayout {
|
|
PanelHeading { text: "SETTINGS"; }
|
|
Rectangle { horizontal-stretch: 1; }
|
|
}
|
|
|
|
HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
|
|
Button {
|
|
text: "Copy";
|
|
enabled: root.enabled;
|
|
horizontal-stretch: 1;
|
|
clicked => { root.copy(); }
|
|
}
|
|
|
|
Button {
|
|
text: "Paste";
|
|
// Enabled on `armed` rather than on the summary being non-empty,
|
|
// so pasting a neutral copy — which clears this image — stays
|
|
// available. Still needs an image to paste *onto*.
|
|
enabled: root.enabled && root.armed;
|
|
horizontal-stretch: 1;
|
|
clicked => { root.paste(); }
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-DEV-6
|
|
// The saved half. On its own row rather than a third of the one above,
|
|
// because copy and paste are a pair — one arms the other — and a button
|
|
// that does neither sitting between them would read as part of that pair.
|
|
Button {
|
|
text: "Presets…";
|
|
enabled: root.enabled;
|
|
clicked => { root.open-presets(); }
|
|
}
|
|
|
|
if root.armed: Caption {
|
|
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
|
|
}
|
|
}
|
|
|
|
|
|
/// Which mode the develop view is in.
|
|
///
|
|
/// `crop` was a bare `bool` on the window and local masking was a panel with
|
|
/// two toggles, so nothing stopped both being on at once — and nothing on
|
|
/// screen said which of them the canvas and the column were obeying. One value
|
|
/// with three states cannot be in two of them, which is the whole reason this
|
|
/// is an enum rather than a tidier pair of flags.
|
|
export enum ViewMode {
|
|
/// The whole photograph. Sliders are global, the canvas pans and zooms.
|
|
photo,
|
|
/// The crop overlay is up and the canvas shows the uncropped frame.
|
|
crop,
|
|
/// The region map is drawn, a click on the photograph selects, and the
|
|
/// column is the mask stack and the selected layer's adjustments.
|
|
local,
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs are drawn on the photograph, a click places one, and the
|
|
/// column describes whichever is selected.
|
|
///
|
|
/// A mode rather than a panel button for the reason the enum exists at
|
|
/// all: arming a click on the canvas is something only one tool may be
|
|
/// doing at a time, and two flags could both be true.
|
|
spots,
|
|
}
|
|
|
|
/// 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 {
|
|
in property <[string]> tabs;
|
|
/// Index into `tabs`, or -1 for "everything".
|
|
in property <int> active-tab: -1;
|
|
in property <bool> enabled: true;
|
|
|
|
callback picked(int);
|
|
|
|
background: Theme.surface;
|
|
height: root.enabled ? layout.preferred-height : 0px;
|
|
visible: root.enabled;
|
|
|
|
// 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 => { root.picked(-1); }
|
|
|
|
Label {
|
|
text: "All";
|
|
emphasised: root.active-tab == -1 || all.has-hover;
|
|
vertical-alignment: center;
|
|
}
|
|
|
|
Rectangle {
|
|
y: parent.height - 2px;
|
|
height: 2px;
|
|
width: parent.width;
|
|
background: root.active-tab == -1 ? Theme.ink : transparent;
|
|
}
|
|
}
|
|
|
|
for tab[i] in root.tabs: tab-area := TouchArea {
|
|
width: name.preferred-width + 10px;
|
|
height: Theme.touch-target;
|
|
mouse-cursor: pointer;
|
|
clicked => { root.picked(i); }
|
|
|
|
name := Label {
|
|
text: tab;
|
|
emphasised: root.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: root.active-tab == i ? Theme.ink : transparent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component AdjustPanel inherits Rectangle {
|
|
in property <[ParamRow]> rows;
|
|
in property <bool> enabled: true;
|
|
/// What these controls are pointed at — the whole photograph, or one mask
|
|
/// layer by name.
|
|
///
|
|
/// **The heading, not a caption beside it.** Selecting a layer re-points
|
|
/// every one of these controls at that layer's chain, and until now the
|
|
/// only sign of it was a sentence in the panel above. Putting the answer
|
|
/// in the heading of the thing that changed means the scope cannot be read
|
|
/// without also reading what it applies to.
|
|
///
|
|
/// Supplied already resolved: whether a layer is selected and what it is
|
|
/// called are session facts, and deriving them here would need this file
|
|
/// to reason about the mask stack.
|
|
in property <string> scope: "ADJUST";
|
|
/// 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);
|
|
callback curve-reset(int);
|
|
/// Plot a different one of the curve's curves.
|
|
callback curve-channel-picked(int);
|
|
/// Return every parameter of one operation to its default — the reset on
|
|
/// a section's own header, beside the panel-wide one.
|
|
callback op-reset(int);
|
|
callback reset-all();
|
|
|
|
// 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 above 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.
|
|
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);
|
|
/// Whether the stock list is open. Pure interface state: it changes no
|
|
/// parameter and the core never hears about it.
|
|
private property <bool> film-expanded: false;
|
|
|
|
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: root.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 => { root.reset-all(); }
|
|
Label {
|
|
text: "reset";
|
|
emphasised: reset.has-hover;
|
|
horizontal-alignment: right;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !root.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 root.enabled && root.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: root.film-stocks[root.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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if root.film-expanded: VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
for stock[i] in root.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 => {
|
|
root.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 == root.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 root.film-can-print: HorizontalLayout {
|
|
spacing: Theme.gap-sm;
|
|
alignment: start;
|
|
print-toggle := TouchArea {
|
|
width: 120px;
|
|
height: Theme.touch-target;
|
|
clicked => { root.film-print-toggled(!root.film-print); }
|
|
Label {
|
|
text: root.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 root.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
|
|
// `root.rows` for each member, and every other row drew
|
|
// nothing. It produced the right picture and could not be
|
|
// dragged.
|
|
//
|
|
// The reason is worth writing down, because it is invisible in
|
|
// a screenshot. The inner repeater's model was `row.group-len`
|
|
// — read off the head row — so it depended on the head row's
|
|
// *identity*. Moving any parameter in the group rewrites that
|
|
// row: its own value changed, or `group-modified` flipped for
|
|
// its neighbours. Rewriting it re-evaluated the repeater, which
|
|
// rebuilt its items, which destroyed the `TouchArea` holding
|
|
// the gesture. The slider took the press, jumped once, and went
|
|
// dead under the finger for the rest of the drag.
|
|
//
|
|
// It only ever affected multi-parameter operations — white
|
|
// balance, highlights and shadows, the mixer — because a lone
|
|
// parameter had no inner repeater to rebuild. Exposure and
|
|
// contrast dragged perfectly the whole time, which is exactly
|
|
// what made it look like a slider bug rather than a layout one.
|
|
//
|
|
// Flat, each element depends only on its own `row`, so an
|
|
// update touches one control and touches nothing structural.
|
|
// It also drops the old hazard of the head row building every
|
|
// control in its group — thirty-six live TouchAreas behind the
|
|
// mixer's twelve visible ones.
|
|
for row[i] in root.rows: VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// The group's name, drawn by the row that heads it, above
|
|
// its own control rather than around the whole run.
|
|
//
|
|
// **A group of one is not a group.** Five of the pipeline's
|
|
// operations carry a single parameter — exposure, contrast,
|
|
// saturation, vibrance, brilliance — and giving each a
|
|
// heading printed the operation's name in caps directly
|
|
// above the same word as the slider's own label. Five times
|
|
// over, that is a column that reads as chrome with controls
|
|
// hidden in it. So a lone parameter is drawn bare; it loses
|
|
// the group reset, which costs nothing, since the slider
|
|
// already resets on double-click and right-click.
|
|
if row.group-head == i && row.group-len > 1: VerticalLayout {
|
|
spacing: 0px;
|
|
padding-top: Theme.gap-sm;
|
|
|
|
// A heading rather than a lid. Several of these groups
|
|
// are two sliders; hiding two sliders behind a triangle
|
|
// costs more than it saves, and a control the user
|
|
// cannot see is one they do not know the pipeline has.
|
|
GroupHeading {
|
|
title: row.op-label;
|
|
modified: row.group-modified;
|
|
// Resetting is what *this* panel's groups do; the
|
|
// heading itself has no opinion about it.
|
|
reset => { root.op-reset(row.op-index); }
|
|
}
|
|
}
|
|
|
|
// A group whose parameters form a grid names each run once.
|
|
// Rust has already stacked the rows so a run is contiguous
|
|
// and marked its first, which is what lets a flat loop draw
|
|
// a heading that belongs to several rows.
|
|
if row.starts-facet: FacetHeading {
|
|
title: row.facet-label;
|
|
}
|
|
|
|
ParamControl {
|
|
data: row;
|
|
curve-samples: root.curve-samples;
|
|
curve-channels: root.curve-channels;
|
|
curve-channel: root.curve-channel;
|
|
drag-changed(on) => { root.slider-dragging = on; }
|
|
param-changed(op, param, v) => {
|
|
root.param-changed(op, param, v);
|
|
}
|
|
param-reset(op, param) => { root.param-reset(op, param); }
|
|
curve-reset(op) => { root.curve-reset(op); }
|
|
curve-channel-picked(i) => { root.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.
|
|
}
|