Five chips in one row declare 440px, and the develop column takes the widest panel's request — so selecting a category mask levered the sidebar past the window's edge, clipping the histogram, the group strip and the subject list. The same trap ChipGrid's comment records for film formats.
1319 lines
60 KiB
Plaintext
1319 lines
60 KiB
Plaintext
// 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 { Develop } from "session.slint";
|
||
import { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
|
||
import { Icon } from "icons.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,
|
||
/// TRACES: FR-DEV-19c
|
||
/// Whether its mask is drawn over the photograph — the eye on its row.
|
||
shown: bool,
|
||
/// The colour it is drawn in, and which swatch that is. Both, because the
|
||
/// row draws the colour and the swatch strip lights the index, and
|
||
/// deriving one from the other here would mean a second copy of the
|
||
/// palette in this file.
|
||
colour: color,
|
||
colour-index: int,
|
||
}
|
||
|
||
/// One selection inside a layer's mask (FR-DEV-19a).
|
||
///
|
||
/// A mask is a fold over these, so the list is ordered and the order is what
|
||
/// it means: a subtraction placed before the part it was meant to cut into
|
||
/// takes nothing away.
|
||
export struct PartRow {
|
||
/// Identity, opaque to this file — the callbacks carry the index instead,
|
||
/// because that is what a row is addressed by on screen.
|
||
id: string,
|
||
/// What this part selects — "brush", "subject", "radial".
|
||
label: string,
|
||
/// 0 adds to the mask, 1 takes away from it. Meaningless on the first.
|
||
join: int,
|
||
/// Whether the brush and the edge controls point at this part.
|
||
selected: bool,
|
||
/// The selection the layer began as. It joins nothing and cannot be
|
||
/// removed — removing it is removing the layer.
|
||
base: bool,
|
||
/// Left out of the build, and kept. The row's eye.
|
||
hidden: 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;
|
||
/// TRACES: FR-DEV-19a
|
||
/// What this layer's mask is built from. Empty unless the row is the one
|
||
/// selected, since the parts of a mask nobody is editing are noise.
|
||
in property <[PartRow]> parts;
|
||
|
||
/// `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();
|
||
|
||
/// TRACES: FR-DEV-19a
|
||
/// Point the tools at one part, change how it joins, or take it out.
|
||
callback part-selected(int);
|
||
callback part-join-picked(int, int);
|
||
callback part-hidden-toggled(int, bool);
|
||
callback part-removed(int);
|
||
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
||
callback part-added(int);
|
||
/// TRACES: FR-DEV-19c
|
||
callback shown-toggled(bool);
|
||
callback colour-picked(int);
|
||
|
||
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;
|
||
|
||
// GESTURE: Show or hide one mask layer
|
||
// where: Develop
|
||
// touch: Tap the ring at the head of its row
|
||
// pointer: Click the ring at the head of its row
|
||
// keys: H, for the selected layer
|
||
// why: Disabling a layer is the before-and-after a local
|
||
// edit constantly wants, so it is one press away rather
|
||
// than inside the row. It is an edit and does take a
|
||
// keys: H, for the selected layer
|
||
// history step, unlike holding "Before" — the layer
|
||
// really is off until it is switched back on.
|
||
//
|
||
// 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");
|
||
}
|
||
}
|
||
|
||
// GESTURE: Show or hide one mask on the photograph
|
||
// where: Develop
|
||
// touch: Tap the eye on its row
|
||
// pointer: Click the eye on its row
|
||
// why: A mask is judged by seeing where it falls, and two
|
||
// are judged by seeing where they meet — so each row
|
||
// has its own eye rather than the panel having one,
|
||
// and the eye is drawn in the colour the mask shows
|
||
// in, so the row says which shape on the picture is
|
||
// its. Nothing about the edit changes: this is how
|
||
// the photograph is looked at, and takes no history
|
||
// step.
|
||
//
|
||
// TRACES: FR-DEV-19c
|
||
eye := TouchArea {
|
||
width: Theme.touch-target;
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.shown-toggled(!root.data.shown); }
|
||
|
||
Icon {
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
size: 18px;
|
||
name: root.data.shown ? "eye" : "eye-closed";
|
||
// Open, in the mask's own colour; closed, in the dim ink
|
||
// every idle control wears — so a glance down the stack
|
||
// says which masks are on the picture and in what.
|
||
ink: root.data.shown
|
||
? root.data.colour
|
||
: (eye.has-hover ? Theme.ink : Theme.ink-dim);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// TRACES: FR-DEV-19a
|
||
// What this mask is built from, in the order it is built.
|
||
//
|
||
// Shown even when there is only one part, because the row is what
|
||
// says the edge controls below belong to a *selection* rather than
|
||
// to the whole layer — and because it is where the next one is
|
||
// added. A photographer who cannot see that a mask has parts has
|
||
// no way to guess that the feather they are dragging moves one of
|
||
// them.
|
||
for part[i] in root.parts: Rectangle {
|
||
height: Theme.touch-target;
|
||
background: part.selected ? Theme.surface : transparent;
|
||
border-radius: Theme.radius;
|
||
|
||
// Declared before the two buttons, so it sits *behind* them: a
|
||
// press on the join chip or the cross does its own job, and a
|
||
// press anywhere else on the row points the tools at the part.
|
||
pick := TouchArea {
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.part-selected(i); }
|
||
}
|
||
|
||
HorizontalLayout {
|
||
padding-left: Theme.gap-sm;
|
||
padding-right: Theme.gap-sm;
|
||
spacing: Theme.gap-sm;
|
||
|
||
// One chip, two states, because there are two ways to
|
||
// join and a photographer flips between them while
|
||
// looking at the picture rather than at a menu.
|
||
join := TouchArea {
|
||
width: Theme.touch-target;
|
||
mouse-cursor: part.base ? MouseCursor.default : MouseCursor.pointer;
|
||
enabled: root.enabled && !part.base;
|
||
clicked => { root.part-join-picked(i, part.join == 0 ? 1 : 0); }
|
||
|
||
Label {
|
||
// The first part joins nothing — a chip saying
|
||
// "adds" on it would be a control that moves and
|
||
// changes no pixel.
|
||
text: part.base ? "◆" : (part.join == 0 ? "+" : "−");
|
||
emphasised: join.has-hover || part.selected;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
Caption {
|
||
text: part.label;
|
||
overflow: elide;
|
||
min-width: 0px;
|
||
max-width: 140px;
|
||
vertical-alignment: center;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
// GESTURE: Leave one part out of a mask, and put it back
|
||
// where: Develop
|
||
// touch: Tap the ring on the part's row
|
||
// pointer: Click the ring on the part's row
|
||
// why: The question a correction raises is whether
|
||
// it did what it was for — whether the stroke
|
||
// filled the shoulder, whether the subtracted
|
||
// gradient took only the sky. Removing it
|
||
// answers that and loses it. The same ring the
|
||
// layer wears, one row down, because it is the
|
||
// same question about a smaller thing.
|
||
//
|
||
// TRACES: FR-DEV-19a
|
||
part-eye := TouchArea {
|
||
width: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.part-hidden-toggled(i, !part.hidden); }
|
||
|
||
Label {
|
||
text: part.hidden ? "○" : "◉";
|
||
emphasised: part-eye.has-hover || !part.hidden;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
if !part.base: cut := TouchArea {
|
||
width: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.part-removed(i); }
|
||
|
||
Label {
|
||
text: "×";
|
||
emphasised: cut.has-hover;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Button {
|
||
text: "+ Add";
|
||
enabled: root.enabled;
|
||
clicked => { root.part-added(0); }
|
||
}
|
||
|
||
Button {
|
||
text: "− Subtract";
|
||
enabled: root.enabled;
|
||
clicked => { root.part-added(1); }
|
||
}
|
||
}
|
||
|
||
// TRACES: FR-DEV-19b
|
||
// **The brush lives with the mask it paints into.** It used to sit
|
||
// at the top of the panel, a screen's height from the row that
|
||
// had just been made, and appeared only once a row was selected —
|
||
// so the whole of the answer to "how do I paint" was a strip
|
||
// somebody had to scroll up to find and had no reason to look
|
||
// for. Here it is under the parts it adds to, which is what a
|
||
// stroke does.
|
||
//
|
||
// In the panel and not in the tool rail, still: the rail's
|
||
// entries arm a gesture for the whole photograph, and a brush has
|
||
// no meaning without a mask to paint into.
|
||
Segmented {
|
||
label: "Brush";
|
||
// Said on the model's own masks especially: a subject or a
|
||
// category arrives approximately right, and the correction
|
||
// the photographer reaches for is a stroke where it stopped
|
||
// short and an erase where it leaked — which becomes a part
|
||
// of this mask, joined to the model's, and can be taken out
|
||
// again from the list above.
|
||
hint: (root.data.kind == "subject" || root.data.kind == "category")
|
||
? "Correct the model's outline by hand: Paint adds to this "
|
||
+ "mask under the pointer, Erase takes away. Each becomes "
|
||
+ "a part above, and a stroke is one step in the history."
|
||
: "Paint adds to this mask under the pointer; Erase takes "
|
||
+ "away. A stroke is one step in the history.";
|
||
options: ["Select", "Paint", "Erase"];
|
||
selected: Masking.tool;
|
||
picked(i) => { Masking.tool-picked(i); }
|
||
}
|
||
|
||
if Masking.tool > 0: SliderRow {
|
||
label: "Size";
|
||
value: Masking.brush-radius;
|
||
default-value: 0.05;
|
||
minimum: 0.005;
|
||
maximum: 0.4;
|
||
precision: 3;
|
||
changed(v) => { Masking.brush-changed(v, Masking.brush-hardness, Masking.brush-flow); }
|
||
reset => { Masking.brush-changed(0.05, Masking.brush-hardness, Masking.brush-flow); }
|
||
}
|
||
|
||
if Masking.tool > 0: SliderRow {
|
||
label: "Hardness";
|
||
value: Masking.brush-hardness;
|
||
default-value: 0.5;
|
||
minimum: 0.0;
|
||
maximum: 1.0;
|
||
precision: 2;
|
||
changed(v) => { Masking.brush-changed(Masking.brush-radius, v, Masking.brush-flow); }
|
||
reset => { Masking.brush-changed(Masking.brush-radius, 0.5, Masking.brush-flow); }
|
||
}
|
||
|
||
if Masking.tool > 0: SliderRow {
|
||
label: "Flow";
|
||
value: Masking.brush-flow;
|
||
default-value: 1.0;
|
||
minimum: 0.05;
|
||
maximum: 1.0;
|
||
precision: 2;
|
||
changed(v) => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, v); }
|
||
reset => { Masking.brush-changed(Masking.brush-radius, Masking.brush-hardness, 1.0); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-19c
|
||
// The colour this mask is shown in. Swatches rather than a picker:
|
||
// six colours that can be told apart at half strength over a
|
||
// photograph is the whole of the requirement, and a wheel would
|
||
// offer a thousand that cannot. Pressing one opens the eye, since
|
||
// choosing a colour for a mask nobody can see would be a control
|
||
// that changes no pixel.
|
||
VerticalLayout {
|
||
spacing: 4px;
|
||
Caption { text: "Shown in"; }
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
for c[i] in Masking.mask-colours: swatch := TouchArea {
|
||
width: Theme.control-height;
|
||
height: Theme.control-height;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.colour-picked(i); }
|
||
|
||
Rectangle {
|
||
width: 18px;
|
||
height: 18px;
|
||
x: (parent.width - self.width) / 2;
|
||
y: (parent.height - self.height) / 2;
|
||
border-radius: 9px;
|
||
background: c;
|
||
// The chosen one is ringed rather than enlarged,
|
||
// so the strip does not shimmer as the choice
|
||
// moves along it.
|
||
border-width: (root.data.shown && root.data.colour-index == i) ? 2px : 0px;
|
||
border-color: Theme.ink;
|
||
opacity: swatch.has-hover || (root.data.shown && root.data.colour-index == i) ? 1.0 : 0.7;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
// Zero, which is the model's own outline — and *not* the
|
||
// position a layer starts at, which is chosen per photograph
|
||
// because a nat of evidence means different things over a
|
||
// smooth sky and over a stone facade (see
|
||
// `dr_segment::Refinement::gentle`). A fixed default here used
|
||
// to be 4, which is half travel and looks like the natural
|
||
// resting place; on an ordinary frame it takes three quarters
|
||
// of the category away, so "reset" emptied the mask.
|
||
default-value: 0;
|
||
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(0); }
|
||
}
|
||
|
||
// 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); }
|
||
}
|
||
|
||
// Wrapped, both of them: five chips in one row is 440px of
|
||
// declared width, and in this column the widest panel's request
|
||
// is what every panel gets — selecting a category mask levered
|
||
// the whole sidebar open past the window's edge and clipped the
|
||
// histogram, the groups strip and the subject list along with
|
||
// it. See `ChipGrid`.
|
||
if root.data.shapeable: Segmented {
|
||
label: "Falloff";
|
||
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
|
||
selected: root.data.falloff;
|
||
columns: 3;
|
||
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;
|
||
columns: 3;
|
||
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The mask stack, what the model found, and everything that can be done to a
|
||
/// layer.
|
||
///
|
||
/// A global rather than eight properties and eighteen callbacks on the panel.
|
||
/// `session.slint` gives the general argument; this panel is the case that
|
||
/// made it, because its instantiation in `app.slint` ran to forty lines of
|
||
/// forwarding — the longest in the file, and every line of it saying the same
|
||
/// thing twice.
|
||
///
|
||
/// The overlay is the other half of the reason. `overlay-hidden` was a
|
||
/// two-way binding between the panel and the window because the panel is where
|
||
/// it is toggled and the canvas is what obeys it; neither owned it, so it was
|
||
/// declared twice and tied together at the instantiation. Here it is declared
|
||
/// once and both read it, which is what a two-way binding was standing in for.
|
||
export global Masking {
|
||
in property <[MaskRow]> masks;
|
||
/// TRACES: FR-DEV-19a
|
||
/// The parts of the selected layer. Empty when none is selected, or when
|
||
/// more than one is — a stroke has one target, and so does an edge.
|
||
in property <[PartRow]> parts;
|
||
/// TRACES: FR-DEV-19b
|
||
/// What a drag on the photograph does: 0 selects, 1 paints, 2 erases.
|
||
///
|
||
/// Written by Rust and read here, like every other `in` property on this
|
||
/// global: `tool-picked` is what the strip reports, and the answer comes
|
||
/// back through this. A version of the handler that recorded nothing left
|
||
/// the strip stuck on "Select" and the brush reachable from no control.
|
||
in property <int> tool: 0;
|
||
/// TRACES: FR-DEV-19c
|
||
/// How shown masks are drawn over the photograph — tint, alpha or edge,
|
||
/// as an index into `dr_pipeline::mask::RevealStyle::ALL`. One style for
|
||
/// every shown mask; *which* masks are shown is each row's own eye.
|
||
///
|
||
/// **Not `overlay-hidden`.** That switch belongs to the region overlay,
|
||
/// which is a picture of what the *model detected*; this is the mask a
|
||
/// layer actually resolves to, feather, morphology, invert and all. Two
|
||
/// overlays that look alike and mean different things is worse than
|
||
/// either, so they are named apart and switched apart.
|
||
in property <int> mask-view-style: 0;
|
||
/// The colours a mask may be shown in — `MASK_COLOURS` in the session,
|
||
/// pushed rather than declared here so the swatch and the shader are
|
||
/// reading one table.
|
||
in property <[color]> mask-colours;
|
||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||
/// and flow.
|
||
in property <float> brush-radius: 0.05;
|
||
in property <float> brush-hardness: 0.5;
|
||
in property <float> brush-flow: 1.0;
|
||
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;
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The photographer's switch for the region overlay, kept separate from
|
||
/// the window's `overlay-on` because the two answer different questions:
|
||
/// `overlay-on` is "is there a mask to draw", which Rust decides, and this
|
||
/// is "do I want to look at it right now", which only they can.
|
||
///
|
||
/// Folding them into one property would mean the next recompute — any
|
||
/// mask edit — silently switched the overlay back on under someone who had
|
||
/// just turned it off to check their work against the photograph.
|
||
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);
|
||
/// TRACES: FR-DEV-16
|
||
/// The same switch for whichever layers are selected, from the keyboard.
|
||
/// No id, because the selection is the session's and this file cannot
|
||
/// see it; Rust answers by flipping each selected layer through the same
|
||
/// path the ring takes, so it is one history step per layer either way.
|
||
callback selected-toggled();
|
||
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);
|
||
|
||
/// TRACES: FR-DEV-19b
|
||
callback tool-picked(int);
|
||
/// TRACES: FR-DEV-19c
|
||
callback mask-view-style-picked(int);
|
||
callback mask-shown-toggled(string, bool);
|
||
callback mask-colour-picked(string, int);
|
||
callback brush-changed(float, float, float);
|
||
/// TRACES: FR-DEV-19a
|
||
callback part-selected(string, int);
|
||
callback part-join-picked(string, int, int);
|
||
/// Whether the part at this index is left out of the build.
|
||
callback part-hidden-toggled(string, int, bool);
|
||
callback part-removed(string, int);
|
||
callback part-added(string, int);
|
||
|
||
callback add-gradient(bool);
|
||
/// TRACES: FR-DEV-19b
|
||
callback add-brush();
|
||
/// `true` asks for the colour range rather than the brightness one.
|
||
callback add-range(bool);
|
||
callback add-subject(int);
|
||
callback add-category(string);
|
||
}
|
||
|
||
export component MaskPanel inherits Rectangle {
|
||
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 Masking.segmented: Value { text: Masking.subjects.length + (Masking.subjects.length == 1 ? " subject" : " subjects");
|
||
}
|
||
}
|
||
|
||
if !Develop.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 Develop.enabled && !Masking.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 Develop.enabled && !Masking.segmented: Button {
|
||
text: Masking.segmenting ? "Looking…" : "Find subjects";
|
||
enabled: !Masking.segmenting;
|
||
primary: true;
|
||
clicked => { Masking.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 Develop.enabled && Masking.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 what was found"
|
||
// is what pressing it will do, not what is currently true.
|
||
//
|
||
// TRACES: FR-DEV-19c
|
||
// **Named for what it actually hides**, which is not the mask. This
|
||
// said "Show the mask" / "Hide the mask" while switching the
|
||
// false-coloured picture of what the *model detected* — and now that
|
||
// there is a control which really does show a mask (the eye on each
|
||
// row of the stack), two things called the same thing and meaning
|
||
// different ones would be worse than either.
|
||
if Develop.enabled && Masking.segmented: Button {
|
||
text: Masking.overlay-hidden ? "Show what was found" : "Hide what was found";
|
||
clicked => { Masking.overlay-hidden = !Masking.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 Develop.enabled && Masking.segmented: Button {
|
||
text: Masking.segmenting ? "Looking closer…" : "Look closer (slower)";
|
||
enabled: !Masking.segmenting;
|
||
clicked => { Masking.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 Develop.enabled && Masking.segmented && Masking.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 Develop.enabled && Masking.segmented: VerticalLayout {
|
||
spacing: 0px;
|
||
for subject in Masking.subjects: subject-row := TouchArea {
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
clicked => { Masking.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 Develop.enabled && Masking.segmented && Masking.categories.length > 0: Caption {
|
||
text: "Or grade a whole category. One adjustment covers every pixel of it.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if Develop.enabled && Masking.segmented: VerticalLayout {
|
||
spacing: 0px;
|
||
for category in Masking.categories: category-row := TouchArea {
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
clicked => { Masking.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 Develop.enabled && Masking.segmented && Masking.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"; }
|
||
|
||
// TRACES: FR-DEV-19c
|
||
// How every shown mask is drawn. One strip for all of them, above
|
||
// the rows whose eyes decide which: a tint beside an outline beside an
|
||
// alpha would be three pictures that cannot be read against each
|
||
// other, where three tints in three colours are one.
|
||
//
|
||
// Three chips rather than a toggle because the three answer three
|
||
// different questions and no one of them answers all three: a tint
|
||
// says whether the right thing is selected, an alpha says where the
|
||
// edge is, an outline says whether that edge is registered against
|
||
// detail the other two hide.
|
||
if Develop.enabled && Masking.masks.length > 0: Segmented {
|
||
label: "Show masks as";
|
||
options: ["Tint", "Alpha", "Edge"];
|
||
selected: Masking.mask-view-style;
|
||
picked(i) => { Masking.mask-view-style-picked(i); }
|
||
}
|
||
|
||
// 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: Develop.enabled;
|
||
clicked => { Masking.add-gradient(false); }
|
||
}
|
||
Button {
|
||
text: "Radial";
|
||
enabled: Develop.enabled;
|
||
clicked => { Masking.add-gradient(true); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-19b
|
||
// **A mask that is nothing but a hand.** Beside the gradients
|
||
// because it belongs to the same answer to "where" — and because
|
||
// without it the only way to reach the brush was to make a
|
||
// gradient nobody wanted and paint into that.
|
||
//
|
||
// It covers nothing until a stroke lands, so pressing this arms
|
||
// the brush as well as making the layer: a row that appeared and
|
||
// changed no pixel, with the pointer still in "select", would look
|
||
// exactly like a button that did nothing.
|
||
Button {
|
||
text: "Paint";
|
||
enabled: Develop.enabled;
|
||
clicked => { Masking.add-brush(); }
|
||
}
|
||
}
|
||
|
||
// 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: Develop.enabled;
|
||
clicked => { Masking.add-range(false); }
|
||
}
|
||
Button {
|
||
text: "Colour";
|
||
enabled: Develop.enabled;
|
||
clicked => { Masking.add-range(true); }
|
||
}
|
||
}
|
||
|
||
if Develop.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 Develop.enabled && Masking.masks.length == 0: Caption {
|
||
text: "No local adjustments. Select a region, or add a gradient.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
for mask in Masking.masks: MaskEntry {
|
||
data: mask;
|
||
enabled: Develop.enabled;
|
||
refining: Masking.refining;
|
||
selected(extend) => { Masking.mask-selected(mask.id, extend); }
|
||
removed => { Masking.mask-removed(mask.id); }
|
||
refined => { Masking.mask-refined(mask.id); }
|
||
toggled(on) => { Masking.mask-toggled(mask.id, on); }
|
||
invert-toggled(on) => { Masking.mask-invert-toggled(mask.id, on); }
|
||
opacity-changed(v) => { Masking.mask-opacity-changed(mask.id, v); }
|
||
feather-changed(v) => { Masking.mask-feather-changed(mask.id, v); }
|
||
refine-changed(v) => { Masking.mask-refine-changed(mask.id, v); }
|
||
falloff-picked(i) => { Masking.mask-falloff-picked(mask.id, i); }
|
||
morphology-picked(i) => { Masking.mask-morphology-picked(mask.id, i); }
|
||
morph-radius-changed(v) => { Masking.mask-morph-radius-changed(mask.id, v); }
|
||
band-changed(lo, hi, soft) => { Masking.mask-band-changed(mask.id, lo, hi, soft); }
|
||
hue-changed(h, w) => { Masking.mask-hue-changed(mask.id, h, w); }
|
||
// Only the selected row is given any, so seven unselected stacks
|
||
// of parts are not built and drawn behind the one being worked on.
|
||
parts: mask.selected ? Masking.parts : [];
|
||
part-selected(i) => { Masking.part-selected(mask.id, i); }
|
||
part-join-picked(i, j) => { Masking.part-join-picked(mask.id, i, j); }
|
||
part-hidden-toggled(i, on) => { Masking.part-hidden-toggled(mask.id, i, on); }
|
||
part-removed(i) => { Masking.part-removed(mask.id, i); }
|
||
part-added(j) => { Masking.part-added(mask.id, j); }
|
||
shown-toggled(on) => { Masking.mask-shown-toggled(mask.id, on); }
|
||
colour-picked(i) => { Masking.mask-colour-picked(mask.id, i); }
|
||
}
|
||
|
||
// 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),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|