Files
DarkRoom/ui/dr-ui/ui/masks.slint
T
dtourolle 68ebf5d78b Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or
found by a model. So the only way to hold back a sky was to draw a line near
where it ended, and the only way to warm skin was to paint round it — both of
which put the edit's edge where the photographer put a gesture rather than
where the picture changes. A gradient across a treeline halos, and an
adjustment traced round a face stops on the outline of a hand.

MaskSource grows two variants that select by what a pixel *is*. Luminance
carries two bounds on the perceptual tone scale plus a softness; Colour carries
an arc of hue, a range of chroma, and one softness for every edge of both. Five
floats and three, so they diff, sync and merge per field under FR-NC-9 exactly
as a gradient's geometry does — the property a stored raster has none of, and
the reason the model's coverage had to sit beside its source rather than inside
it.

The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the
demosaiced source as a sixth binding and two new modes read it: decode, balance,
pull a clipped photosite back to neutral, apply the camera matrix, then weigh
the band. Nothing crosses to the CPU but the numbers and the matrix, and each
mask texel averages its own footprint in the source, so a band lands on the tone
an area is rather than on whichever texel a proxy grid happened to land on.

The photograph it measures is the one the camera recorded, before this edit. A
band over the edited result would slide out from under the edit as the edit was
made — raising the highlights would change which pixels counted as highlights,
and the slider would chase its own mask.

Feather, falloff and morphology stay off a range layer, which is what
`shapeable` already meant. All three are functions of the signed distance from
a boundary, and a range has no boundary to be at a distance from; its edge is
the softness of its own band, in the band's units. Offering them would be four
controls that move and change nothing.
2026-09-06 19:01:48 +02:00

860 lines
37 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,
/// How strictly a category is cut back to the pixels whose colour agrees
/// with it, in nats. Zero is the model's own weighting.
refine: float,
/// Whether this layer has a colour model fitted to refine against. Only a
/// category has one, and only where the frame gave enough to fit it.
refinable: bool,
/// 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,
/// Whether the band controls apply — a mask made from a range of the
/// photograph's own values rather than from a shape (FR-DEV-10).
///
/// The complement of `shapeable` in practice but not by definition: a
/// range has no boundary in the picture, so it has no distance from one
/// for a feather or a dilation to act on, and its edge is the softness of
/// its own band instead.
ranged: bool,
/// Whether that range is over colour rather than over brightness, so the
/// hue arc applies as well as the band.
chromatic: bool,
/// The band's lower and upper bounds, and the fade at each edge. Tone
/// positions for a brightness range, colourfulness for a colour one —
/// two readings of one control, which is why they share three fields.
band-lo: float,
band-hi: float,
band-softness: float,
/// A colour range's arc: centre and half-width, in turns.
hue: float,
hue-width: float,
/// 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,
}
/// One photographic category the scene model weighed.
export struct CategoryRow {
/// The descriptor's name — "sky", "vegetation". Carried rather than an
/// index because the descriptor is editable and an index would repoint.
name: string,
label: string,
/// 0..1 of the frame. Shown for the same reason a subject's score is: it
/// tells the photographer whether the category is worth reaching for
/// before they spend a click finding out.
coverage: float,
}
component MaskEntry inherits Rectangle {
in property <MaskRow> data;
in property <bool> enabled: true;
/// Whether a refine pass is running (for any row — only one runs at a
/// time), so this row's button can say so and refuse a second press.
in property <bool> refining: false;
/// `true` when the click should add this row to the selection rather
/// than replace it with just this row.
callback selected(bool);
callback removed();
callback toggled(bool);
callback invert-toggled(bool);
callback opacity-changed(float);
callback feather-changed(float);
callback refine-changed(float);
callback falloff-picked(int);
callback morphology-picked(int);
callback morph-radius-changed(float);
/// Lower bound, upper bound, softness — all three, because they are one
/// control: dragging the lower past the upper swaps them, and a callback
/// per field would have to answer that with a third of the answer.
callback band-changed(float, float, float);
/// Centre hue and half-width, in turns.
callback hue-changed(float, float);
callback refined();
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` carries no modifiers, so a plain click and a
// multi-select click need the pointer event itself: released, over
// the row, with control or command held is "add to the selection"
// rather than "replace it".
pointer-event(event) => {
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
root.selected(event.modifiers.control || event.modifiers.meta);
}
}
}
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;
// Both lines below are bounded for the reason the subject
// row is: a mask's label and kind come from the model, and an
// unbounded `Text` asks for its whole string at layout time
// even when `elide` means it will never draw it. A mask *is* a
// segmentation result, so without this the column moved when a
// subject was clicked as well as when one was found.
Label {
text: root.data.label;
emphasised: root.data.selected || touch.has-hover;
overflow: elide;
min-width: 0px;
max-width: 160px;
}
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;
min-width: 0px;
max-width: 160px;
// 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;
}
}
// Only a subject has a detection behind it worth re-running. A
// gradient's edge is geometry the photographer placed, and there
// is nothing for the model to find twice.
if root.data.kind == "subject": Button {
text: root.refining ? "Refining…" : "Refine mask";
enabled: !root.refining;
clicked => { root.refined(); }
}
// Which pixels are in the mask at all. Above the edge controls
// because it decides the outline they then treat, and unlike them
// it rebuilds the distance field — the same cost as a close or an
// open, and the reason it commits on release rather than on every
// sample of a drag.
if root.data.refinable: SliderRow {
label: "Refine";
hint: "Drop pixels whose colour does not belong — a flag in "
+ "the sky, a chimney, a bare branch — and pull the "
+ "outline onto the edge the photograph actually has. "
+ "Zero is the model's own outline.";
value: root.data.refine;
default-value: 4;
minimum: 0;
maximum: 8;
// Tenths, not whole nats. The interval between a flag going
// and a cloud going is under three nats wide, so whole steps
// would give the control about three useful positions inside
// the range it exists to explore.
precision: 1;
changed(v) => { root.refine-changed(v); }
reset => { root.refine-changed(4); }
}
// The band (FR-DEV-10). Above the edge controls because for a
// range there are none: a band has no boundary in the picture and
// therefore no distance from one, so `shapeable` is false and the
// softness below is the whole of this mask's edge.
//
// Degrees and percentages rather than the turns and fractions
// stored. Both are the units the quantity is discussed in, and a
// hue slider reading 0.06 is a control the photographer has to
// learn before it means anything.
if root.data.chromatic: SliderRow {
label: "Hue";
hint: "Which colour the mask follows. Skin sits just past red.";
value: root.data.hue * 360;
default-value: 22;
minimum: 0;
maximum: 360;
changed(v) => { root.hue-changed(v / 360, root.data.hue-width); }
reset => { root.hue-changed(0.06, root.data.hue-width); }
}
if root.data.chromatic: SliderRow {
label: "Spread";
hint: "How far either side of that colour the mask reaches.";
value: root.data.hue-width * 360;
default-value: 18;
minimum: 0;
// Half the circle each way is the whole of it, so this is the
// widest arc that is still a selection.
maximum: 180;
changed(v) => { root.hue-changed(root.data.hue, v / 360); }
reset => { root.hue-changed(root.data.hue, 0.05); }
}
if root.data.ranged: SliderRow {
// Named by what the end of the band *is* rather than by
// "from", which says nothing on its own and says the wrong
// thing on the colour range, where the axis is not brightness.
label: root.data.chromatic ? "Least colourful" : "Darkest";
value: root.data.band-lo * 100;
default-value: root.data.chromatic ? 15 : 50;
minimum: 0;
maximum: 100;
changed(v) => {
root.band-changed(v / 100, root.data.band-hi, root.data.band-softness);
}
reset => {
root.band-changed(root.data.chromatic ? 0.15 : 0.5,
root.data.band-hi,
root.data.band-softness);
}
}
if root.data.ranged: SliderRow {
label: root.data.chromatic ? "Most colourful" : "Brightest";
value: root.data.band-hi * 100;
default-value: 100;
minimum: 0;
maximum: 100;
changed(v) => {
root.band-changed(root.data.band-lo, v / 100, root.data.band-softness);
}
reset => {
root.band-changed(root.data.band-lo, 1.0, root.data.band-softness);
}
}
if root.data.ranged: SliderRow {
label: "Softness";
hint: "How gradually the mask fades in at each end of the "
+ "range. At zero it is a threshold, and a threshold "
+ "over a photograph's own tones draws contour lines "
+ "across a smooth sky.";
value: root.data.band-softness * 100;
default-value: 15;
minimum: 0;
maximum: 100;
changed(v) => {
root.band-changed(root.data.band-lo, root.data.band-hi, v / 100);
}
reset => {
root.band-changed(root.data.band-lo, root.data.band-hi, 0.15);
}
}
// 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;
in property <[CategoryRow]> categories;
/// A region map has been computed for this image.
in property <bool> segmented: false;
/// One is being computed now.
in property <bool> segmenting: false;
/// A refine pass is running for one subject layer.
in property <bool> refining: false;
/// Whether the photographer has hidden the overlay to see the photograph.
/// Two-way bound to the window's own, so the canvas and this control never
/// disagree about it.
in-out property <bool> overlay-hidden: false;
/// `true` asks for the slower, tiled pass.
callback segment(bool);
/// `true` when the click should add the layer to the selection rather
/// than replace it.
callback mask-selected(string, bool);
callback mask-removed(string);
callback mask-refined(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-refine-changed(string, float);
callback mask-falloff-picked(string, int);
callback mask-morphology-picked(string, int);
callback mask-morph-radius-changed(string, float);
callback mask-band-changed(string, float, float, float);
callback mask-hue-changed(string, float, float);
callback add-gradient(bool);
/// `true` asks for the colour range rather than the brightness one.
callback add-range(bool);
callback add-subject(int);
callback add-category(string);
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.
layout := 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(false); }
}
// 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;
}
// Hiding the overlay is not the "Overlay" button this panel used to
// have, and the distinction is the reason it is back. That one *armed*
// the overlay: local mode could be entered and still show nothing,
// which is the fault the comment below records. This one only takes an
// overlay that is already there out of the way for a moment, which is
// the one thing a photographer needs constantly and had no way to do —
// a mask is judged against the photograph under it, and you cannot see
// that photograph through the thing describing it.
//
// Reads as its own action rather than its state: "Show the mask" is
// what pressing it will do, not what is currently true.
if root.enabled && root.segmented: Button {
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
clicked => { root.overlay-hidden = !root.overlay-hidden; }
}
// The second pass, offered rather than taken automatically.
//
// The model's input is a fixed 640x640 square and every frame is
// letterboxed into it, so a subject that is small in the frame reaches
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
// and an outline traced at 80px is what a coarse mask is. Tiling runs
// the model over overlapping windows instead, so that bird arrives at
// its own size.
//
// It costs one inference per tile: about 2.8s for a 3x2 grid against
// 470ms for the whole frame. That is the wrong trade for the usual
// photograph, where the subject fills much of the frame and the first
// pass is already the best answer available — so it is a button and
// not a default, and it says what it costs.
if root.enabled && root.segmented: Button {
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
enabled: !root.segmenting;
clicked => { root.segment(true); }
}
// 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;
// **`elide` is a paint-time behaviour, and this is a
// layout-time problem.** A `Text` asks for the width of
// its whole string whether or not it will draw all of
// it, so without a stated maximum this row asked for
// whatever the model happened to return, that became
// `layout.preferred-width`, the panel publishes that as
// its `min-width`, and the develop column takes the
// widest minimum any panel declares. The column
// therefore moved the instant segmentation finished —
// a photograph the user was looking at, jumping
// sideways because a label said "traffic light".
//
// Stated as a maximum for the reason `ChipGrid`
// declares its width from its column count rather than
// from its options: what a panel asks for must follow
// from its structure, never from its data. Past this
// the row elides, which is what `elide` was for.
//
// 160px is the same judgement as `ChipGrid`'s 88px
// chip — comfortable for the class names this model
// returns, and narrow enough that a subject list
// cannot be what sets the column.
min-width: 0px;
max-width: 160px;
}
Value { text: round(subject.score * 100) + "%"; }
}
}
}
// --- whole categories ---------------------------------------------
//
// Below the subjects rather than above, and the order is the argument:
// clicking the photograph is how a local adjustment usually starts, so
// the things that were *found* come first. Categories are the move you
// reach for deliberately — grade the sky, not this one bird.
if root.enabled && root.segmented && root.categories.length > 0: Caption {
text: "Or grade a whole category. One adjustment covers every pixel of it.";
wrap: word-wrap;
}
if root.enabled && root.segmented: VerticalLayout {
spacing: 0px;
for category in root.categories: category-row := TouchArea {
height: Theme.touch-target;
mouse-cursor: pointer;
clicked => { root.add-category(category.name); }
HorizontalLayout {
spacing: Theme.gap-sm;
Label {
text: category.label;
emphasised: category-row.has-hover;
horizontal-stretch: 1;
overflow: elide;
// Same layout-time reason as the subject row above:
// a Text asks for its whole string's width whether or
// not it elides, and the develop column takes the
// widest minimum any panel declares.
max-width: 160px;
vertical-alignment: center;
}
Label {
// Coverage as a percentage, which is the unit the
// number means something in. A category under half a
// percent never reaches this list at all.
// A plain Label is already `ink-dim`; only
// `emphasised` lifts it, and a coverage readout is
// exactly the thing that should not compete with the
// name beside it.
text: round(category.coverage * 100) + "%";
vertical-alignment: center;
}
}
}
}
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); }
}
}
// Ranges, on their own row rather than beside the gradients. A
// gradient answers "where" and these answer "what", and putting four
// buttons in one row would suggest four flavours of the same thing —
// where the difference between them is the only thing a photographer
// needs to understand to reach for the right one.
//
// "Tone" and "Colour", not "luminance range" and "colour range". What
// the photographer is choosing is which property the mask follows.
HorizontalLayout {
spacing: Theme.gap-sm;
Button {
text: "Tone";
enabled: root.enabled;
clicked => { root.add-range(false); }
}
Button {
text: "Colour";
enabled: root.enabled;
clicked => { root.add-range(true); }
}
}
if root.enabled: Caption {
text: "A tone or colour mask follows the photograph's own values, "
+ "so the edit blends where the picture does.";
wrap: word-wrap;
}
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;
refining: root.refining;
selected(extend) => { root.mask-selected(mask.id, extend); }
removed => { root.mask-removed(mask.id); }
refined => { root.mask-refined(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); }
refine-changed(v) => { root.mask-refine-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); }
band-changed(lo, hi, soft) => { root.mask-band-changed(mask.id, lo, hi, soft); }
hue-changed(h, w) => { root.mask-hue-changed(mask.id, h, w); }
}
// 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.
}
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// The mask's handles, drawn on the photograph.
///
/// A linear or radial mask could be made and then never moved: there was
/// nothing to grab, so a radial sat at the centre of the frame at its default
/// size for ever.
///
/// **Placed over the fitted image, not the canvas box.** The photograph is
/// letterboxed inside the canvas and Slint does not report the fitted rect, so
/// the develop view derives it and gives this component exactly that rect —
/// which is why everything in here is a plain fraction of `root.width` and
/// `root.height` rather than an offset from someone else's origin. Rust
/// supplies the positions already mapped through the framing — the same map
/// the shader applies — so a handle follows the mask through a zoom, a pan, a
/// crop and a straightening rather than sitting where the mask used to be.
///
/// **Drawn small, grabbed large.** The visible dot is 14px because a bigger
/// one would hide the edge it is placed on, and the `TouchArea` is a full
/// touch target, centred on it — `Button` establishes the same split. These
/// are the first controls in the application meant to be dragged on the
/// photograph, and a 12-inch tablet has no hover to reveal them with and no
/// modifier to qualify them by, so what is drawn is all there is (FR-UI-7).
export component GradientHandles inherits Rectangle {
in property <[GradientHandle]> handles;
/// role, from-x, from-y, to-x, to-y — all fractions of this rect.
callback dragged(HandleRole, float, float, float, float);
/// One history step per gesture, not per frame.
callback released();
background: transparent;
for handle in root.handles: Rectangle {
x: handle.x * root.width - self.width / 2;
y: handle.y * root.height - self.height / 2;
width: Theme.touch-target;
height: Theme.touch-target;
// The centre moves the whole mask, so it is filled; the others shape
// it and are rings. Shape rather than colour, because the handles sit
// on a photograph and any colour they carried would be read as part
// of it.
property <bool> solid: handle.role == HandleRole.centre;
Rectangle {
width: handle.role == HandleRole.rotate ? 12px : 14px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 2px;
// White with a dark ring, so the handle is visible against a blown
// sky and against a black frame — one treatment, both extremes, no
// theme token because this is drawn over the image and not over
// the interface.
border-color: #00000099;
background: parent.solid ? #ffffff : #ffffff44;
}
drag := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: parent.solid
? MouseCursor.move
: MouseCursor.crosshair;
// Where the press landed, in the same fractions the callback
// reports in. Captured on the way down so the whole gesture is
// measured from one origin.
property <float> from-x;
property <float> from-y;
function fraction-x(local-x: length) -> float {
return (parent.x + local-x) / max(root.width, 1px);
}
function fraction-y(local-y: length) -> float {
return (parent.y + local-y) / max(root.height, 1px);
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
self.from-x = self.fraction-x(self.pressed-x);
self.from-y = self.fraction-y(self.pressed-y);
}
if (ev.kind == PointerEventKind.up) {
root.released();
}
}
moved => {
if (self.pressed) {
root.dragged(
handle.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
}