Files
DarkRoom/ui/dr-ui/ui/adjust.slint
T
dtourolleandClaude Opus 5 96a7b405c2 Say which photograph the sliders are pointed at
Selecting a mask layer silently re-points about thirty controls at that layer's
chain. Same panel, same order, same sliders, different meaning — and the only
thing that said so was a sentence in the panel above, which a photographer
reaching for the exposure slider has no reason to read. An exposure change
lands on the whole frame when it was meant for a face, or the reverse; both are
silent, and both are discovered later. `ui-navigation.md` §1.1 calls it the
dangerous one and it is: the others in that document cost time, this one costs
work.

The remedy is the classic one for a modal fault — make the mode visible — and
the application already had the pattern. Crop arms a canvas interaction, draws
an overlay, gives the column one job and is left by the control that entered
it. Local masking is the same animal built as a peer panel, and that is what
created the ambiguity. So `crop-mode` stops being a bare boolean and becomes
one value of a three-state mode, which is the point: two modes could both be on
before, and now that is not a state the interface can be in rather than one it
is tested against.

**One strip, not two.** The mode control was going to sit beside the group
strip that filters the adjustments, which is two controls above one column
answering the same question — what am I working on. They are one control now,
`Crop · Local │ All · Light · Colour`, which is the shape Lightroom Mobile's
bottom strip has for the same reason. The two halves are different kinds of
state and are drawn differently: a mode is a chip that fills with the accent
when it is on, a group is a word with a rule under it. That difference is what
lets both be read at once, which they routinely are — picking Light while a
mask is selected filters *that layer's* chain and does not leave the mode.
Dropping the scope on a group press would be the same fault coming back from
the other end, and would make Light mean two things depending on where it was
pressed.

The strip stays pinned above the develop column rather than moving to the top
of the canvas as the document proposed. The half that filters the column
belongs to the column, and the photograph is the subject. The canvas keeps one
button, which now names the mode it leaves rather than saying "Done" — that was
unambiguous with one mode and would not be with two — because the column can be
closed on a narrow window and no mode may be inescapable.

Entering a mode is a side effect, so Rust owns it rather than the strip writing
the property: crop drops the zoom, local turns the overlay on, and leaving
clears the selection. That last one is the fix. The "Overlay" and "Select"
toggles are gone because they armed things that are simply what the mode *is* —
a mode that has to be switched on separately is one you can enter and have do
nothing. Escape and the Android back gesture join `back_step` as one
`LeaveMode` rather than a second exit concept, and the mode is left before the
zoom is: it was entered later, and it is the bigger step back.

The heading is where the scope goes. Not a caption beside the panel, the
heading *of* the panel that changed — `ADJUST` becomes the layer's name, the
same string the selected row in the stack shows. That is the difference between
describing a hazard and removing it.

**Handles on the photograph.** A linear or radial mask could be created and
then not moved, so a radial sat at the centre of the frame at its default size
for ever. Three faults stood in the way of drawing one.

The first is that a gradient did not render at all until the model had run. The
rasteriser was built on the way out of `segment` and the array's size was read
*off* the segmentation, so a gradient added to an unsegmented photograph
produced nothing — silently, in the same way exports and thumbnails once did:
the shader still emits the layer's block and the empty placeholder multiplies it
by zero. The proxy size is a property of the photograph. Both are derived from
it now, and deliberately at the same size rather than by coincidence, because a
subject's distance field is sampled against that array.

The second is hit-testing. A handle is drawn in output coordinates and stored
in source ones, and between them lie the crop, the zoom, the pan, the
straightening and the turns. `Framing::source_at` is `wgsl_prologue` evaluated
on the CPU, kept in that file beside it so that keeping the two in step is one
file's problem — a handle mapped through anything less drifts off the mask the
moment the view moves, which is exactly what masks are rasterised in source
space to avoid.

The third is that a drag is a displacement, not a destination. Each handle
answers to the movement of the pointer since the press, applied to where the
mask was when the press landed. Snapping the handle to the pointer instead
jerks it by up to half a touch target on the first press, and the target is
finger-sized because a tablet has no hover to reveal a control and no modifier
to qualify it.

A ramp gets three handles — centre, width, angle. An ellipse gets three too:
centre and one per semi-axis, the major one carrying the direction as well as
the length, because where an axis is put says both. It had a fourth, and it is
gone: standing off the shape by a fixed distance, the rotation arm began
outside the photograph at the size a new radial is created at, so the first
thing anyone saw was a control they could not reach without first shrinking the
mask.

Two faults here were found by looking at the screen rather than at the source,
both of the kind that cannot be found any other way. A `1px` rule with a size
and no position is *centred* by Slint, so the seam between the photograph and
the column was a hairline down the middle of the panel, through the histogram
and every slider under it — twice, once in `app.slint` and once in
`AdjustPanel`. And handing Slint a fresh model for the handles on every pointer
event made the repeater rebuild its items, taking the `TouchArea` holding the
gesture with them: the handle jumped once and then went dead under a finger
that was still down. `develop.rs` carries the same warning about the parameter
rows, where it broke slider drags; the model is rewritten in place now.

The tests worth having are the ones about ambiguity and about the map. That the
same row reads the frame's value, then the layer's, then the frame's again is
§1.1 in one assertion. That dragging a handle onto another gradient's matching
handle *produces* that gradient closes the loop between the two directions of
the framing map, through a view that is cropped, zoomed, panned, straightened
and quarter-turned at once — a one-legged map is invisible when the framing is
neutral, because then both legs are the identity.

Not done here: the histogram still reports the whole frame while the sliders
edit a layer. That disagreement is real and is N3's, which this unblocks. The
strip has room for a Brush entry beside Crop and Local when the painted masks
land in the core, and it needs nothing here but the canvas interaction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 13:20:41 +02:00

1010 lines
42 KiB
Plaintext

// Generic adjustment controls, generated from pipeline capabilities.
//
// **Nothing here names an operation.** There is no "exposure slider" and no
// "saturation section" — the panel walks a model the core supplies and
// instantiates one control per entry, choosing the control from the
// parameter's declared kind (ARCH §4.3, FR-DEV-3a). Adding an operation to
// the pipeline makes it appear here with no change to this file
// (FR-DEV-3c).
import { Theme } from "theme.slint";
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
// One parameter, flattened for Slint's model system.
//
// Flat rather than nested because Slint models do not nest cleanly; the Rust
// side flattens the capability tree into this and carries the indices needed
// to route a change back.
export struct ParamRow {
// Routing back to the core. Opaque to this file.
op-index: int,
param-index: int,
// Resolved display strings. Resolution happens in Rust against the UI's
// catalogue, because the core deals in localisation keys only.
op-label: string,
param-label: string,
// Grouping, derived in Rust from where `op-index` changes.
//
// The model is flat and Slint cannot slice one, so a group says where it
// begins and how long it is and the panel indexes back into `rows` from
// there. `group-head` is this row's group's first index — a row heads its
// group exactly when its own index equals it, which is what replaced the
// core-supplied `starts-group` flag (ARCH §4.3a: the core does not decide
// that the panel has sections).
group-head: int,
group-len: int,
// Any parameter of this operation differs from its default. Identical on
// every row of a group, because the heading is one of those rows and
// cannot see the others.
group-modified: bool,
// Which control to build. Mirrors ParamKind, plus the widget kinds an
// operation can request through its presentation.
kind: string, // "scalar" | "bool" | "enum" | "curve"
// A run of rows inside a group, for an operation whose parameters form a
// grid rather than a list.
//
// The colour mixer is twelve hue bands times three channels, and as a flat
// list it read as "Hue / Saturation / Luminance" twelve times over with
// nothing saying which band any row belonged to. Rust stacks the rows so
// each channel's twelve are together and marks the first of each run; this
// file names the run and lets the swatch identify the row.
//
// `facet-label` is the run's name and is empty on an ordinary parameter,
// which is every operation but the mixer.
facet-label: string,
starts-facet: bool,
// Where this row's subject sits on the hue wheel, in degrees, or -1 for a
// row whose subject is not a colour.
//
// A sentinel because a Slint struct cannot carry an optional, and -1
// rather than any in-range value because 0° is red — a real band, and the
// first one.
swatch-hue: float,
value: float,
default-value: float,
minimum: float,
maximum: float,
precision: int,
unit: string,
// Curve rows only: the point coordinates, x and y interleaved.
//
// Carried on the row rather than fetched separately because a Slint
// model row is the unit of update — splitting them would let the curve
// and its points refresh out of step. Empty for every other kind.
//
// `param-index` on a curve row is the index of the *first* point
// parameter, so a drag routes back by offsetting from it.
points: [float],
// Enum rows only: the variant names, in index order.
//
// Resolved in Rust against the UI's catalogue, like every other label
// here — the core publishes localisation keys and never a display string.
// `value` on such a row is the chosen index, which is why an enum needs no
// separate selection field.
choices: [string],
}
// The name of a group of controls, and what can be done to the group.
//
// What is left of `Section` once the collapsing is taken out: the name, the dot
// that says something inside differs from its default, and the reset. The
// sidebar is a column of instruments, and an instrument behind a lid is one the
// user has to remember to open — several of these held a single slider, so the
// lid was most of the row.
//
// The reset stays visible rather than appearing on hover, as the section's did.
// A hover-only control is one no finger can find, and this panel is now
// expected to be worked with a thumb.
component GroupHeading inherits Rectangle {
in property <string> title;
/// Something inside differs from its default.
in property <bool> modified: false;
/// Whether this group has anything to reset.
in property <bool> has-reset: true;
callback reset();
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-right: Theme.gap-sm;
spacing: Theme.gap-sm;
PanelHeading {
text: root.title;
sub: true;
horizontal-stretch: 1;
}
Rectangle {
width: 6px;
height: 6px;
y: (parent.height - self.height) / 2;
border-radius: 3px;
background: Theme.modified;
visible: root.modified;
}
Rectangle {
width: 34px;
visible: root.has-reset;
reset-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
y: (parent.height - self.height) / 2;
enabled: root.has-reset;
mouse-cursor: pointer;
clicked => { root.reset(); }
}
Caption {
text: "reset";
emphasised: reset-touch.has-hover;
horizontal-alignment: right;
width: 100%;
height: 100%;
}
}
}
}
// A parameter's value, at the precision its descriptor declares.
//
// A global rather than the same ternary written into every control that shows
// one. There are two such controls now and the rule belongs to neither of
// them: precision comes from the descriptor, so a control in stops reads 1.25
// while one in whole units reads 25, and a copy that fell behind would show
// the same parameter two ways in the same panel. `SliderTrack`'s preamble is
// the longer version of this argument.
global Readout {
public pure function of(data: ParamRow) -> string {
return data.precision == 0
? Math.round(data.value) + data.unit
: (Math.round(data.value * 100) / 100) + data.unit;
}
}
// One generated parameter: a label, a readout, and the track above.
//
// The readout is *not* editable, where the settings page's `SliderRow` pairs
// the same track with a number box. That is a considered difference rather than
// an inconsistency: this column is 280px wide and the colour mixer alone puts
// thirty-six of these in it, so a text box per row would be most of the width
// and a keyboard target nobody is aiming for. The number is still reachable —
// the track resets on double-click and right-click.
component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.data.param-label;
readout: Readout.of(root.data);
// The one readout in the panel that has moved off its default is what
// the eye is hunting for, and `modified` is the only thing left to say
// it with once hue is gone.
modified: root.data.value != root.data.default-value;
SliderTrack {
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
maximum: root.data.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
}
}
// The name of a run of rows inside a group: the mixer's Hue, Saturation and
// Luminance.
//
// A `Label` rather than a third `PanelHeading`. Two levels of
// caps-with-tracking already stack above it — the panel's own `ADJUST` and the
// operation's name — and a third in the same treatment would read as their
// peer instead of as something *inside* the operation. Sentence case at the
// same size says "a part of the group above" without another size or colour.
component FacetHeading inherits Rectangle {
in property <string> title;
height: Theme.control-height;
HorizontalLayout {
padding-left: Theme.gap-sm;
padding-top: Theme.gap-sm;
Label { text: root.title; }
}
}
// One faceted parameter: a swatch, a track and a readout, on a single line.
//
// **The swatch is the label.** Twelve of these sit under a heading that
// already names the channel, so the only thing a row has left to say is which
// band it edits — and a 12px square says it in a fraction of the width the
// word would take. That is what makes twelve rows fit where four did: the
// mixer is thirty-six controls, and at `ParamSlider`'s two-line 46px it was
// most of a screen of scrolling with the band name absent from every row of it
// anyway.
//
// **The name is not thrown away, it moves.** It is the row's accessible label,
// so a screen reader says "Orange" where the eye reads the colour, and the
// catalogue in `labels.rs` is where the mapping is written down for anyone who
// cannot separate two squares by eye. A row identified by colour *alone*
// would be a control some photographers could not use, which is why the
// spoken name is part of the design and not an afterthought.
component SwatchSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Forwarded from the track, for the panel's Flickable.
callback drag-changed(bool);
// The track's own height plus a hairline of air. Denser than a
// `ParamSlider` because the label line it would need is gone, not because
// the touch target shrank — `SliderTrack` still owns a full-width hit area
// and the gestures behind it (FR-UI-3).
height: Theme.touch-target / 2 + 4px;
accessible-role: slider;
accessible-label: root.data.param-label;
accessible-value: Readout.of(root.data);
accessible-value-minimum: root.data.minimum;
accessible-value-maximum: root.data.maximum;
HorizontalLayout {
padding-left: Theme.gap-sm;
spacing: Theme.gap-sm;
Swatch {
hue: root.data.swatch-hue;
// Centred against the track rather than the row, which a layout
// would do for a stretching child and cannot do for a fixed one.
y: (parent.height - self.height) / 2;
}
SliderTrack {
horizontal-stretch: 1;
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
maximum: root.data.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
Value {
text: Readout.of(root.data);
modified: root.data.value != root.data.default-value;
placeholder: root.data.value == root.data.default-value;
compact: true;
// Fixed and right-aligned: a readout sized to its own text would
// pull the track's end left and right as the number changed, and
// twelve tracks that each ended somewhere different would be
// impossible to compare down the column.
width: 30px;
horizontal-alignment: right;
}
}
}
// A slider the interface names itself, rather than one generated from a row.
//
// The straighten angle is reached through the session's own accessor, not
// through a row index, so there is no `ParamRow` to feed it. Only the labels
// and the source of the numbers differ — the track is the same component, and
// so is every gesture it recognises.
component PlainSlider inherits Rectangle {
in property <string> label;
in property <float> value;
in property <float> default-value: 0.0;
in property <float> minimum: -1.0;
in property <float> maximum: 1.0;
in property <string> unit;
callback changed(float);
callback reset();
callback drag-changed(bool);
height: 46px;
ControlRow {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
modified: root.value != root.default-value;
SliderTrack {
value: root.value;
default-value: root.default-value;
minimum: root.minimum;
maximum: root.maximum;
changed(v) => { root.changed(v); }
reset => { root.reset(); }
engaged-changed(on) => { root.drag-changed(on); }
}
}
}
// **The control registry: one row in, one control out.**
//
// Slint cannot instantiate a component from a runtime string, so mapping a
// declared kind to a control is necessarily a chain of `if`s. The thing worth
// insisting on is that there is exactly *one* such chain. There were two — the
// panel draws a lone parameter bare and a group under a heading, and each
// branch wrote out its own list of kinds — so `enum` would have had to be added
// in both, and a kind added to only one would appear or vanish depending on how
// many parameters its operation happened to declare.
//
// Everything below routes back through `param-changed` by index. This component
// knows a curve point spans two parameters and a swatch names a hue band; it
// knows nothing about which operation it is drawing, which is the property that
// makes it a registry rather than a panel.
component ParamControl inherits Rectangle {
in property <ParamRow> data;
/// Curve rows only; ignored by every other kind.
in property <[float]> curve-samples;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
callback drag-changed(bool);
height: layout.preferred-height;
layout := VerticalLayout {
spacing: 0px;
alignment: start;
// A row whose subject is a colour is identified by that colour; every
// other scalar keeps its name. The two differ only in what stands in
// for the label — the track, the gestures and the routing are the same
// underneath.
if root.data.kind == "scalar" && root.data.swatch-hue >= 0: SwatchSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
if root.data.kind == "scalar" && root.data.swatch-hue < 0: ParamSlider {
data: root.data;
drag-changed(on) => { root.drag-changed(on); }
changed(v) => {
root.param-changed(root.data.op-index, root.data.param-index, v);
}
reset => {
root.param-reset(root.data.op-index, root.data.param-index);
}
}
// A fixed list of alternatives. The value *is* the index, so picking
// one is an ordinary parameter change and needs no separate route.
//
// Chips rather than a dropdown for the same reason the settings page
// uses them: these lists are short, and a collapsed menu hides the
// alternatives behind a click. ARCH §4.3 names the dropdown as the
// pointer presentation of the same kind, so this is where that choice
// will be made when the modality switch lands.
if root.data.kind == "enum": Segmented {
label: root.data.param-label;
options: root.data.choices;
selected: Math.round(root.data.value);
picked(i) => {
root.param-changed(root.data.op-index, root.data.param-index, i);
}
}
if root.data.kind == "curve": CurveEditor {
points: root.data.points;
samples: root.curve-samples;
drag-changed(on) => { root.drag-changed(on); }
// A point carries two parameters, so the parameter index is the
// row's base plus the point's offset. This component still knows
// nothing about which operation it belongs to.
point-moved(point, x, y) => {
root.param-changed(
root.data.op-index, root.data.param-index + point * 2, x);
root.param-changed(
root.data.op-index, root.data.param-index + point * 2 + 1, y);
}
reset => { root.curve-reset(root.data.op-index); }
}
}
}
// Crop, rotation, flips and straightening — the framing controls.
//
// **Why this is hand-built when the rest of the panel is generated.** The
// generic path renders one slider per parameter, which for framing means eight
// of them: four crop edges the user would have to type coordinates into, and a
// "Rotate" slider running 0..3. Every one of those is a worse control than the
// gesture it stands for — a crop is dragged on the photograph, and a quarter
// turn is a button. So framing is presented rather than generated, and the
// generic panel drops it (see `AdjustPanel.skip-op`).
//
// This does not weaken ARCH §4.3: nothing here reads a parameter *value* out
// of a descriptor or routes by index. It calls named session actions, which is
// what a bespoke widget for a known stage is entitled to do.
export component GeometryPanel inherits Rectangle {
in property <bool> enabled: true;
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;
callback param-changed(int, int, float);
callback param-reset(int, int);
callback curve-reset(int);
/// Return every parameter of one operation to its default — the reset on
/// a section's own header, beside the panel-wide one.
callback op-reset(int);
callback reset-all();
background: Theme.surface;
// A slider below has claimed the current gesture, so this panel must stop
// competing for it. See the long note on `ParamSlider`'s `claimed`: without
// this the Flickable takes any drag that drifts 8px vertically, which under
// a finger is every drag.
/// 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;
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); }
}
}
}
}
}
// 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.
}