Every route to a layer began with a selection — a gradient, a band, a subject, a category — and painting was reachable only by making one of those and joining a painted part to it. So the answer to "brush a correction onto this corner of the sky" was "add a radial gradient you do not want, then paint into that", which is not an answer. Paint sits beside Linear and Radial and makes a layer whose base is a brush. It covers nothing until a stroke lands in it, so pressing it arms the brush and shows the mask as well: a row that appeared and changed no pixel, with the pointer still in "select", is indistinguishable from a button that did nothing.
1164 lines
52 KiB
Plaintext
1164 lines
52 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;
|
||
// 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); }
|
||
}
|
||
|
||
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.
|
||
///
|
||
/// 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 the selected layer's mask is drawn over the photograph: 0 not at
|
||
/// all, then tint, alpha and edge — `dr_pipeline::mask::RevealStyle::ALL`
|
||
/// shifted by one so that zero can mean off.
|
||
///
|
||
/// **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: 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);
|
||
/// TRACES: FR-DEV-19c
|
||
callback mask-view-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);
|
||
/// 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"; }
|
||
|
||
// 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); }
|
||
}
|
||
|
||
// TRACES: FR-DEV-19c
|
||
// **Seeing the mask, which every tool above is unusable without.**
|
||
// Nobody can refine an edge they are not being shown, and until this
|
||
// existed the only thing drawn on the photograph was the model's
|
||
// detections — which know nothing of a layer's feather, its falloff,
|
||
// its morphology, its invert or its opacity, and nothing at all about
|
||
// a gradient or a stroke.
|
||
//
|
||
// Four chips rather than a toggle because the three styles 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.
|
||
//
|
||
// Beside the tool strip and under the same condition, because both
|
||
// describe one selected mask — and `columns: 2` for the reason
|
||
// `ChipGrid` exists: four chips in a row would set the width of the
|
||
// develop column and take every other panel's controls off the edge.
|
||
if Develop.enabled && Masking.parts.length > 0: Segmented {
|
||
label: "Show mask";
|
||
options: ["Off", "Tint", "Alpha", "Edge"];
|
||
columns: 2;
|
||
selected: Masking.mask-view;
|
||
picked(i) => { Masking.mask-view-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 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 ("Show mask",
|
||
// above), 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"; }
|
||
|
||
// 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-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),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|