Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T

1056 lines
45 KiB
Plaintext

// Generic adjustment controls, generated from pipeline capabilities.
//
// **Nothing here names an operation.** There is no "exposure slider" and no
// "saturation section" — the panel walks a model the core supplies and
// instantiates one control per entry, choosing the control from the
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
// the pipeline makes it appear here with no change to this file
// (FR-DEV-3c).
import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
// One parameter, flattened for Slint's model system.
//
// Flat rather than nested because Slint models do not nest cleanly; the Rust
// side flattens the capability tree into this and carries the indices needed
// to route a change back.
export struct ParamRow {
// Routing back to the core. Opaque to this file.
op-index: int,
param-index: int,
// Resolved display strings. Resolution happens in Rust against the UI's
// catalogue, because the core deals in localisation keys only.
op-label: string,
param-label: string,
// Grouping, derived in Rust from where `op-index` changes.
//
// The model is flat and Slint cannot slice one, so a group says where it
// begins and how long it is and the panel indexes back into `rows` from
// there. `group-head` is this row's group's first index — a row heads its
// group exactly when its own index equals it, which is what replaced the
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
// that the panel has sections).
group-head: int,
group-len: int,
// Any parameter of this operation differs from its default. Identical on
// every row of a group, because the heading is one of those rows and
// cannot see the others.
group-modified: bool,
// Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "enum" | "curve"
// A run of rows inside a group, for an operation whose parameters form a
// grid rather than a list.
//
// The colour mixer is twelve hue bands times three channels, and as a flat
// list it read as "Hue / Saturation / Luminance" twelve times over with
// nothing saying which band any row belonged to. Rust stacks the rows so
// each channel's twelve are together and marks the first of each run; this
// file names the run and lets the swatch identify the row.
//
// `facet-label` is the run's name and is empty on an ordinary parameter,
// which is every operation but the mixer.
facet-label: string,
starts-facet: bool,
// Where this row's subject sits on the hue wheel, in degrees, or -1 for a
// row whose subject is not a colour.
//
// A sentinel because a Slint struct cannot carry an optional, and -1
// rather than any in-range value because 0° is red — a real band, and the
// first one.
swatch-hue: float,
value: float,
default-value: float,
minimum: float,
maximum: float,
precision: int,
unit: string,
// Curve rows only: the point coordinates, x and y interleaved.
//
// Carried on the row rather than fetched separately because a Slint
// model row is the unit of update — splitting them would let the curve
// and its points refresh out of step. Empty for every other kind.
//
// `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it.
points: [float],
// Enum rows only: the variant names, in index order.
//
// Resolved in Rust against the UI's catalogue, like every other label
// here — the core publishes localisation keys and never a display string.
// `value` on such a row is the chosen index, which is why an enum needs no
// separate selection field.
choices: [string],
}
// The name of a group of controls, and what can be done to the group.
//
// What is left of `Section` once the collapsing is taken out: the name, the dot
// that says something inside differs from its default, and the reset. The
// sidebar is a column of instruments, and an instrument behind a lid is one the
// user has to remember to open — several of these held a single slider, so the
// lid was most of the row.
//
// The reset stays visible rather than appearing on hover, as the section's did.
// A hover-only control is one no finger can find, and this panel is now
// expected to be worked with a thumb.
component GroupHeading inherits Rectangle {
in property <string> title;
/// Something inside differs from its default.
in property <bool> modified: false;
/// Whether this group has anything to reset.
in property <bool> has-reset: true;
callback reset();
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
PanelHeading {
text: root.title;
sub: true;
horizontal-stretch: 1;
}
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.modified;
visible: root.modified;
}
Rectangle {
width: 34px;
visible: root.has-reset;
reset-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-reset;
mouse-cursor: pointer;
clicked => { root.reset(); }
}
Caption {
text: "reset";
emphasised: reset-touch.has-hover;
horizontal-alignment: right;
width: 100%;
height: 100%;
}
}
}
}
// A parameter's value, at the precision its descriptor declares.
//
// A global rather than the same ternary written into every control that shows
// one. There are two such controls now and the rule belongs to neither of
// them: precision comes from the descriptor, so a control in stops reads 1.25
// while one in whole units reads 25, and a copy that fell behind would show
// the same parameter two ways in the same panel. `SliderTrack`'s preamble is
// the longer version of this argument.
global Readout {
public pure function of(data: ParamRow) -> string {
return data.precision == 0
? Math.round(data.value) + data.unit
: (Math.round(data.value * 100) / 100) + data.unit;
}
}
// One generated parameter: a label, a readout, and the track above.
//
// The readout is *not* editable, where the settings page's `SliderRow` pairs
// the same track with a number box. That is a considered difference rather than
// an inconsistency: this column is 280px wide and the colour mixer alone puts
// thirty-six of these in it, so a text box per row would be most of the width
// and a keyboard target nobody is aiming for. The number is still reachable —
// the track resets on double-click and right-click.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.data.param-label;
readout: Readout.of(root.data);
// The one readout in the panel that has moved off its default is what
// the eye is hunting for, and `modified` is the only thing left to say
// it with once hue is gone.
modified: root.data.value != root.data.default-value;
SliderTrack {
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
maximum: root.data.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
}
}
// The name of a run of rows inside a group: the mixer's Hue, Saturation and
// Luminance.
//
// A `Label` rather than a third `PanelHeading`. Two levels of
// caps-with-tracking already stack above it — the panel's own `ADJUST` and the
// operation's name — and a third in the same treatment would read as their
// peer instead of as something *inside* the operation. Sentence case at the
// same size says "a part of the group above" without another size or colour.
component FacetHeading inherits Rectangle {
in property <string> title;
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-top: Theme.gap-sm;
Label { text: root.title; }
}
}
// One faceted parameter: a swatch, a track and a readout, on a single line.
//
// **The swatch is the label.** Twelve of these sit under a heading that
// already names the channel, so the only thing a row has left to say is which
// band it edits — and a 12px square says it in a fraction of the width the
// word would take. That is what makes twelve rows fit where four did: the
// mixer is thirty-six controls, and at `ParamSlider`'s two-line 46px it was
// most of a screen of scrolling with the band name absent from every row of it
// anyway.
//
// **The name is not thrown away, it moves.** It is the row's accessible label,
// so a screen reader says "Orange" where the eye reads the colour, and the
// catalogue in `labels.rs` is where the mapping is written down for anyone who
// cannot separate two squares by eye. A row identified by colour *alone*
// would be a control some photographers could not use, which is why the
// spoken name is part of the design and not an afterthought.
component SwatchSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
// The track's own height plus a hairline of air. Denser than a
// `ParamSlider` because the label line it would need is gone, not because
// the touch target shrank — `SliderTrack` still owns a full-width hit area
// and the gestures behind it (FR-UI-3).
height: Theme.touch-target / 2 + 4px;
accessible-role: slider;
accessible-label: root.data.param-label;
accessible-value: Readout.of(root.data);
accessible-value-minimum: root.data.minimum;
accessible-value-maximum: root.data.maximum;
HorizontalLayout {
padding-left: Theme.gap-sm;
spacing: Theme.gap-sm;
Swatch {
hue: root.data.swatch-hue;
// Centred against the track rather than the row, which a layout
// would do for a stretching child and cannot do for a fixed one.
y: (parent.height - self.height) / 2;
}
SliderTrack {
horizontal-stretch: 1;
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
maximum: root.data.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
Value {
text: Readout.of(root.data);
modified: root.data.value != root.data.default-value;
placeholder: root.data.value == root.data.default-value;
compact: true;
// Fixed and right-aligned: a readout sized to its own text would
// pull the track's end left and right as the number changed, and
// twelve tracks that each ended somewhere different would be
// impossible to compare down the column.
width: 30px;
horizontal-alignment: right;
}
}
}
// A slider the interface names itself, rather than one generated from a row.
//
// The straighten angle is reached through the session's own accessor, not
// through a row index, so there is no `ParamRow` to feed it. Only the labels
// and the source of the numbers differ — the track is the same component, and
// so is every gesture it recognises.
component PlainSlider inherits Rectangle {
in property <string> label;
in property <float> value;
in property <float> default-value: 0.0;
in property <float> minimum: -1.0;
in property <float> maximum: 1.0;
in property <string> unit;
callback changed(float);
callback reset();
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value;
SliderTrack {
value: root.value;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
}
}
// **The control registry: one row in, one control out.**
//
// Slint cannot instantiate a component from a runtime string, so mapping a
// declared kind to a control is necessarily a chain of `if`s. The thing worth
// insisting on is that there is exactly *one* such chain. There were two — the
// panel draws a lone parameter bare and a group under a heading, and each
// branch wrote out its own list of kinds — so `enum` would have had to be added
// in both, and a kind added to only one would appear or vanish depending on how
// many parameters its operation happened to declare.
//
// Everything below routes back through `param-changed` by index. This component
// knows a curve point spans two parameters and a swatch names a hue band; it
// knows nothing about which operation it is drawing, which is the property that
// makes it a registry rather than a panel.
component ParamControl inherits Rectangle {
in property <ParamRow> data;
/// Curve rows only; ignored by every other kind.
in property <[float]> curve-samples;
/// 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);
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;
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 GeometryPanel 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;
callback rotate(int);
callback flip-h-toggled();
callback flip-v-toggled();
callback angle-changed(float);
callback angle-reset();
callback reset();
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// A heading, not a collapsible.
//
// These controls are the reason the column is open; folding them away
// behind a triangle put the sidebar's own contents one tap further
// from the photograph and gave every group a lid that had to be
// learned. `GroupHeading` keeps what the section was actually for —
// naming the group, flagging that it holds an edit, and offering the
// reset — without the hiding.
GroupHeading {
title: "GEOMETRY";
modified: root.modified;
has-reset: root.modified;
reset => { root.reset(); }
}
VerticalLayout {
spacing: Theme.gap-sm;
padding-bottom: Theme.gap-sm;
// 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.
HorizontalLayout {
spacing: Theme.gap-sm;
IconButton {
icon: "rotate-ccw";
enabled: root.enabled;
clicked => { root.rotate(-1); }
}
IconButton {
icon: "rotate-cw";
enabled: root.enabled;
clicked => { root.rotate(1); }
}
Rectangle { horizontal-stretch: 1; }
IconButton {
icon: "flip-h";
active: root.flip-h;
enabled: root.enabled;
clicked => { root.flip-h-toggled(); }
}
IconButton {
icon: "flip-v";
active: root.flip-v;
enabled: root.enabled;
clicked => { root.flip-v-toggled(); }
}
}
PlainSlider {
label: "Straighten";
value: root.angle;
default-value: 0.0;
minimum: -root.max-straighten;
maximum: root.max-straighten;
unit: "°";
changed(v) => { root.angle-changed(v); }
reset => { root.angle-reset(); }
}
}
}
}
// The panel: a heading per multi-parameter operation, a control per parameter.
//
// **Why the loop is shaped the way it is.** `rows` is flat, and Slint can
// neither slice a model nor nest a `for` over a run of it. What it *can* do is
// repeat over an integer — `for n in row.group-len` — so each group's heading
// row renders its whole group by indexing back into `rows` from `group-head`,
// and every other row renders nothing.
//
// **Nothing here collapses.** Every group was a `Section` with a disclosure
// triangle until it became clear what that cost on a tablet: five of the
// pipeline's operations carry one parameter, so the lid was most of the row,
// and a control behind a lid is one the user does not know the pipeline has.
// The column is closed as a whole from the status strip instead, which is the
// control that was actually wanted.
// TRACES: FR-DEV-6
// Copying this photograph's settings, and pasting settings onto it.
//
// Buttons rather than a keyboard shortcut *alone*, because this has to work on
// a tablet where there is no modifier key to hold and no menu bar to hang the
// action from. The desktop shortcuts exist as well, wired in Rust; they are an
// accelerator for a control that is on screen either way, which is what keeps
// the feature discoverable on both platforms.
//
// The paste button carries what would be pasted rather than the bare word.
// "Paste" alone asks the user to remember what they copied and, crucially,
// whether the crop is coming with it — a question the label answers by
// naming the count the *current* scope would apply.
export component TransferPanel inherits VerticalLayout {
in property <bool> enabled: true;
/// Whether anything has been copied yet. Distinct from the clipboard
/// being *neutral*: a copy of an unedited frame is a real thing to paste,
/// since it clears the target.
in property <bool> armed: false;
/// What a paste would apply — "3 adjustments", or "Neutral".
in property <string> summary;
/// Whether the clipboard holds framing the current scope is dropping.
/// Only then is it worth saying anything about the crop.
in property <bool> framing-withheld: false;
callback copy();
callback paste();
padding: Theme.gap;
spacing: Theme.gap-sm;
HorizontalLayout {
PanelHeading { text: "SETTINGS"; }
Rectangle { horizontal-stretch: 1; }
}
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Copy";
enabled: root.enabled;
horizontal-stretch: 1;
clicked => { root.copy(); }
}
Button {
text: "Paste";
// Enabled on `armed` rather than on the summary being non-empty,
// so pasting a neutral copy — which clears this image — stays
// available. Still needs an image to paste *onto*.
enabled: root.enabled && root.armed;
horizontal-stretch: 1;
clicked => { root.paste(); }
}
}
if root.armed: Caption {
text: root.summary + (root.framing-withheld ? " · crop not included" : "");
}
}
/// 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,
}
/// The strip: what the photographer is working on.
///
/// Two kinds of entry, deliberately together.
///
/// **Modes** — crop and local — change the canvas as well as the column. They
/// are drawn as chips and lit with the accent, which means *active* everywhere
/// else in this interface and means exactly that here.
///
/// **Groups** — the rest — filter the adjustments to one kind. They are
/// underlined instead, because the accent is already spoken for and because
/// they are a different sort of state: a mode is something you are *in*, a
/// group is something you are *looking at*.
///
/// Keeping them apart visually is what lets them be independent. Picking a
/// group in local mode filters the selected layer's chain and does not leave
/// the mode, 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 reintroduced 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). The two modes are not
/// operations — crop is a gesture on the canvas and local is a scope — so
/// naming them breaks nothing.
export component ModeStrip inherits Rectangle {
in property <[string]> tabs;
/// Index into `tabs`, or -1 for "everything".
in property <int> active-tab: -1;
in property <ViewMode> mode: ViewMode.photo;
in property <bool> enabled: true;
callback picked(int);
callback mode-picked(ViewMode);
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. Two modes
// plus however many groups the operation set declares is already more than
// a 280px column holds, and the column is where this is pinned.
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;
// A chip per mode. Drawn as an outline that fills when active rather
// than as an underline, so at a glance the strip reads as two runs
// and it is never ambiguous which half a lit entry belongs to.
//
// **Where a brush goes.** Painting a mask is a third mode of exactly
// this shape — it arms a canvas gesture and scopes the column — so it
// joins this list and the `ViewMode` enum, and needs nothing else here.
// `MaskSource::Brush` and the stroke calls on `MaskLayer` land in the
// core separately; what is missing on this side is only the canvas
// interaction, which is the gradient handles' neighbour.
for entry in [
{ label: "Crop", value: ViewMode.crop },
{ label: "Local", value: ViewMode.local },
]: mode-chip := TouchArea {
width: mode-name.preferred-width + 2 * Theme.gap-sm;
height: Theme.touch-target;
mouse-cursor: pointer;
property <bool> on: root.mode == entry.value;
// Pressing the mode you are already in leaves it, which is what
// makes the strip the way out as well as the way in — the same
// control both directions, as the crop button was.
clicked => {
root.mode-picked(self.on ? ViewMode.photo : entry.value);
}
Rectangle {
y: (parent.height - self.height) / 2;
height: Theme.control-height;
width: parent.width;
border-radius: Theme.radius;
border-width: 1px;
border-color: mode-chip.on ? Theme.active : Theme.rule;
background: mode-chip.on
? Theme.active-dim
: (mode-chip.has-hover ? Theme.hover : transparent);
}
mode-name := Text {
text: entry.label;
// Dark on the lit fill, which is near-white: the same
// inversion `Button`'s primary state makes.
color: mode-chip.on ? Theme.ground : Theme.ink-dim;
font-size: Theme.text-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-alignment: center;
}
}
// The divider between the two kinds. One pixel, and it is what stops
// the strip reading as one undifferentiated row of five words.
Rectangle {
width: 1px;
height: Theme.touch-target;
background: Theme.rule;
}
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();
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;
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"; }
// 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.
}