Every panel in the develop column declared its inputs and its callbacks and had `app.slint` bind each one to a property or a callback on the window root. That is fine while a panel is drawn once. N9 draws them a second time, in the portrait dock, and the wiring is what would have to be copied: `MaskPanel` alone ran to forty lines of forwarding, and a callback added to one copy and not the other compiles, renders, and simply does nothing on the layout nobody was looking at. So the wiring moved to Slint globals. A panel reads the global and calls the global; Rust hooks the global instead of the window; and the instantiation in the column is now the panel's name and a pair of braces — every one of the ten children of the column, with no property that differs by placement left to supply. There is a global per panel family rather than one for all of them, and the reason is an import cycle. Each panel's model struct — `ParamRow`, `MaskRow`, `HistogramView` — is declared in the panel's own file, so a single global holding `[MaskRow]` and `[ParamRow]` would have to live in a file importing `masks.slint` and `adjust.slint` while both imported the global back, which Slint rejects. Breaking that needs six model declarations relocated, which is a change to the data model and not to the plumbing this is about. A global beside the panel it serves also lets each name drop the prefix it was carrying only because the window root is one flat namespace: `root.spot-radius` is `Repair.radius`, and `root.peaking-on` is `Peaking.showing`. `session.slint` is new and holds the two facts every family needs and none of them owns: whether there is an open photograph to edit, and which mode the view is in, with the three readings of the mode derived once instead of at each of the dozen places that tested one. `ViewMode` moves there from `adjust.slint`, where it was only ever a lodger. Nothing on screen changes. What is not here: the tool rail and the status strip still take their properties at the instantiation, because they are drawn once and N9 does not copy them; the preset sheet's own state stays on the window, because the library grid opens the same sheet and a global cannot bind the window's state — which is why `Transfer.open-presets` is handled in `presets.rs`, beside the summary it already had to compute.
1086 lines
48 KiB
Plaintext
1086 lines
48 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 { 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,
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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-removed(int);
|
||
/// Join a fresh painted correction: 0 adds, 1 subtracts.
|
||
callback part-added(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
|
||
// 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
|
||
// 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");
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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); }
|
||
}
|
||
}
|
||
|
||
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); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
in property <int> tool: 0;
|
||
/// 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);
|
||
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);
|
||
callback brush-changed(float, float, float);
|
||
/// TRACES: FR-DEV-19a
|
||
callback part-selected(string, int);
|
||
callback part-join-picked(string, int, int);
|
||
callback part-removed(string, int);
|
||
callback part-added(string, int);
|
||
|
||
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);
|
||
}
|
||
|
||
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"; }
|
||
|
||
// TRACES: FR-DEV-19b
|
||
// **In the panel and not in the tool rail.** The rail's entries arm a
|
||
// gesture for the whole photograph; a brush has no meaning without a
|
||
// mask to paint into, and a rail entry that lit up with nothing
|
||
// selected would either do nothing or silently make a layer. Here the
|
||
// strip can simply be absent until there is something to paint on.
|
||
if Develop.enabled && Masking.parts.length > 0: Segmented {
|
||
options: ["Select", "Paint", "Erase"];
|
||
selected: Masking.tool;
|
||
picked(i) => { Masking.tool-picked(i); }
|
||
}
|
||
|
||
if Develop.enabled && 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 Develop.enabled && 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 Develop.enabled && 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); }
|
||
}
|
||
|
||
// --- 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 the mask" is
|
||
// what pressing it will do, not what is currently true.
|
||
if Develop.enabled && Masking.segmented: Button {
|
||
text: Masking.overlay-hidden ? "Show the mask" : "Hide the mask";
|
||
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"; }
|
||
|
||
// 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); }
|
||
}
|
||
}
|
||
|
||
// 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-removed(i) => { Masking.part-removed(mask.id, i); }
|
||
part-added(j) => { Masking.part-added(mask.id, j); }
|
||
}
|
||
|
||
// 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),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|