Files
DarkRoom/ui/dr-ui/ui/masks.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

409 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Local adjustments: the mask stack, the region overlay, and the subjects the
// model recognised (FR-DEV-3).
//
// The panel is deliberately thin. A mask layer holds an ordinary develop
// chain, so selecting one hands the *existing* adjust panel that chain — there
// are no per-layer sliders here and there must not be, or every operation
// added to `ops/` would need a local twin.
import { Theme } from "theme.slint";
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
import { Segmented, SliderRow } from "controls.slint";
/// One layer in the stack.
export struct MaskRow {
// Identity, opaque to this file — the callbacks hand it straight back.
id: string,
label: string,
/// What kind of mask it is, for the row's second line.
kind: string,
enabled: bool,
/// The layer the develop panel is currently editing.
selected: bool,
inverted: bool,
opacity: float,
/// Edge transition half-width, as a fraction of the frame's shorter edge.
feather: float,
/// Index into `Falloff::ALL`.
falloff: int,
/// Index into `Morphology::ALL`.
morphology: int,
morph-radius: float,
/// Whether the edge controls apply. A gradient has its own geometry and
/// does not go through the distance field, so showing them would offer
/// controls that do nothing.
shapeable: bool,
/// Its region ids came from a different segmentation than the one loaded,
/// so applying it would produce a confidently wrong shape.
stale: bool,
/// Whether it currently changes any pixel. A bare selection does not, and
/// saying so is what stops it reading as broken.
adjusted: bool,
}
/// Which part of a gradient a canvas handle drags.
///
/// Named by the job rather than by the field, because the two gradients keep
/// different things in the same place: `edge` is a linear ramp's width and a
/// radial's major semi-axis, and `cross` is the radial's minor one.
export enum HandleRole { centre, edge, cross, rotate }
/// One draggable point on the photograph.
///
/// Positioned in fractions of the image **as it is currently shown** — after
/// the crop, the zoom and the pan — because that is the only space this file
/// can draw in. Rust maps the mask's own source-space geometry into it on
/// every frame, so the handle sits on the mask rather than beside it.
export struct GradientHandle {
role: HandleRole,
x: float,
y: float,
}
/// Something the model found.
export struct SubjectRow {
index: int,
label: string,
/// 0..1. Shown rather than hidden: the detector is a shortcut, not an
/// authority, and a weak guess presented like a strong one makes the tool
/// look wrong when it was merely unsure.
score: float,
}
component MaskEntry inherits Rectangle {
in property <MaskRow> data;
in property <bool> enabled: true;
callback selected();
callback removed();
callback toggled(bool);
callback invert-toggled(bool);
callback opacity-changed(float);
callback feather-changed(float);
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
height: layout.preferred-height;
background: root.data.selected ? Theme.surface-raised : transparent;
border-radius: Theme.radius;
touch := TouchArea {
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.selected(); }
}
layout := VerticalLayout {
padding: Theme.gap-sm;
spacing: 2px;
HorizontalLayout {
spacing: Theme.gap-sm;
// The eye. Disabling a layer is the A/B a local edit constantly
// wants, so it is one press away rather than inside the row.
visibility := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.toggled(!root.data.enabled); }
Label {
text: root.data.enabled ? "◉" : "○";
emphasised: visibility.has-hover || root.data.enabled;
horizontal-alignment: center;
vertical-alignment: center;
}
}
VerticalLayout {
horizontal-stretch: 1;
alignment: center;
spacing: 0px;
Label {
text: root.data.label;
emphasised: root.data.selected || touch.has-hover;
overflow: elide;
}
Caption {
// Elided rather than wrapped: this sits beside two fixed
// targets in a row, and wrapping would give the rows of a
// stack different heights for no gain.
overflow: elide;
// Three states worth distinguishing, and each has a
// different remedy: stale needs the segmentation re-run,
// unadjusted needs a slider moved, and the ordinary case
// needs nothing said about it beyond what it is.
text: root.data.stale
? "needs re-segmenting"
: (root.data.adjusted
? root.data.kind
: root.data.kind + " · no adjustment yet");
}
}
remove := TouchArea {
width: Theme.touch-target;
height: Theme.touch-target;
mouse-cursor: pointer;
enabled: root.enabled;
clicked => { root.removed(); }
Label {
text: "×";
emphasised: remove.has-hover;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// Opacity and invert appear only on the selected layer. On every row
// they would triple the height of a stack of eight and put seven sets
// of controls in front of the one being worked on.
if root.data.selected: VerticalLayout {
spacing: Theme.gap-sm;
padding-top: Theme.gap-sm;
SliderRow {
label: "Opacity";
value: root.data.opacity;
default-value: 1.0;
minimum: 0.0;
maximum: 1.0;
changed(v) => { root.opacity-changed(v); }
reset => { root.opacity-changed(1.0); }
}
invert := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.invert-toggled(!root.data.inverted); }
Label {
text: root.data.inverted ? "Inverted — edit applies outside" : "Invert";
emphasised: invert.has-hover || root.data.inverted;
}
}
// The edge. All of it reads one signed distance field, so these
// are free to move — none of them recomputes anything except a
// compound morphology.
if root.data.shapeable: SliderRow {
label: "Feather";
hint: "How far the edit fades across the edge.";
value: root.data.feather * 1000;
default-value: 4;
minimum: 0;
maximum: 60;
changed(v) => { root.feather-changed(v / 1000); }
reset => { root.feather-changed(0.004); }
}
if root.data.shapeable: Segmented {
label: "Falloff";
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
selected: root.data.falloff;
picked(i) => { root.falloff-picked(i); }
}
if root.data.shapeable: Segmented {
label: "Edge";
hint: "Grow or shrink the outline the model drew. Close fills "
+ "pinholes; open removes specks.";
options: ["None", "Grow", "Shrink", "Close", "Open"];
selected: root.data.morphology;
picked(i) => { root.morphology-picked(i); }
}
if root.data.shapeable && root.data.morphology != 0: SliderRow {
label: "Amount";
value: root.data.morph-radius * 1000;
default-value: 0;
minimum: 0;
maximum: 40;
changed(v) => { root.morph-radius-changed(v / 1000); }
reset => { root.morph-radius-changed(0); }
}
}
}
}
export component MaskPanel inherits Rectangle {
in property <bool> enabled: true;
in property <[MaskRow]> masks;
in property <[SubjectRow]> subjects;
/// A region map has been computed for this image.
in property <bool> segmented: false;
/// One is being computed now.
in property <bool> segmenting: false;
callback segment();
callback mask-selected(string);
callback mask-removed(string);
callback mask-toggled(string, bool);
callback mask-invert-toggled(string, bool);
callback mask-opacity-changed(string, float);
callback mask-feather-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback add-gradient(bool);
callback add-subject(int);
background: Theme.surface;
// **Flat, deliberately.** An earlier version grouped each state into its
// own `if ...: VerticalLayout { ... }`. The nested layout under-reported
// its height, so the divider and the mask row below it were placed over
// the "Find regions" button — the sort of fault that is invisible in the
// source and obvious the moment anyone looks at the panel. Conditioning
// each child on its own keeps every element a direct child of one layout,
// which is the layout that measures them.
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
alignment: start;
HorizontalLayout {
PanelHeading { text: "MASKS"; }
Rectangle { horizontal-stretch: 1; }
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
}
}
if !root.enabled: Caption { text: "No image"; }
// --- the region map ---------------------------------------------
//
// Segmentation is a thing the user starts. Half a second of watershed
// and inference is too long to spend on every photograph opened, and
// most edits never need one.
if root.enabled && !root.segmented: Caption {
text: "Find the subjects in this photograph, so a mask can follow one.";
// Wrapped, or this sentence's single-line width becomes the
// panel's minimum and levers the fixed 280px develop column open,
// taking every other panel's controls off the right edge with it.
wrap: word-wrap;
}
if root.enabled && !root.segmented: Button {
text: root.segmenting ? "Looking…" : "Find subjects";
enabled: !root.segmenting;
primary: true;
clicked => { root.segment(); }
}
// No "Overlay" button and no "Select" button. Both switched on things
// that are now simply what local mode *is*: entering it draws the
// region map and makes a click on the photograph mean "select". A mode
// whose behaviour has to be armed separately is a mode that can be
// entered and still do nothing, which is what these two allowed.
if root.enabled && root.segmented: Caption {
text: "Click a subject in the photograph to mask it.";
wrap: word-wrap;
}
// Granularity, labelled by what it does rather than by its number:
// "detail" is what a photographer is choosing between, where "300
// regions" is an implementation detail they would have to learn.
// --- what the model found ----------------------------------------
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
// Says where these came from and how they differ from a region.
// A list of four beside a count of three thousand invites exactly
// one question, and the panel should answer it rather than
// provoke it.
text: "Click one to mask it. The outline is the model's, so soften or grow it below.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
spacing: 0px;
for subject in root.subjects: subject-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-subject(subject.index); }
HorizontalLayout {
spacing: Theme.gap-sm;
Label {
text: subject.label;
emphasised: subject-row.has-hover;
horizontal-stretch: 1;
overflow: elide;
}
Value { text: round(subject.score * 100) + "%"; }
}
}
}
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
text: "Nothing recognised. The model knows people, animals and vehicles — "
+ "a landscape has no subject for it to find. Add a gradient instead.";
wrap: word-wrap;
}
Rectangle {
height: 1px;
background: Theme.rule;
}
// --- the stack -----------------------------------------------------
Caption { text: "Masks"; }
// Gradients need no segmentation, so they are offered whether or not
// one has been computed — a graduated filter over a sky is a local
// adjustment that never needed to know what a sky is.
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Linear";
enabled: root.enabled;
clicked => { root.add-gradient(false); }
}
Button {
text: "Radial";
enabled: root.enabled;
clicked => { root.add-gradient(true); }
}
}
if root.enabled && root.masks.length == 0: Caption {
text: "No local adjustments. Select a region, or add a gradient.";
wrap: word-wrap;
}
for mask in root.masks: MaskEntry {
data: mask;
enabled: root.enabled;
selected => { root.mask-selected(mask.id); }
removed => { root.mask-removed(mask.id); }
toggled(on) => { root.mask-toggled(mask.id, on); }
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
}
// No caption saying which chain the sliders below are pointed at.
//
// That sentence used to be the *only* indication that selecting a
// layer had silently re-scoped thirty controls, and describing a
// hazard in a caption is not the same as removing it. The adjust
// panel's own heading names the layer it is editing now, which puts
// the answer on the thing that changed rather than above it.
}
}