The crop rectangle, the gradient handles and the repair discs were 480 lines inside `canvas-area` in app.slint, while the panels that drive them already lived in adjust.slint, masks.slint and spots.slint. spots.slint even opens by describing "what is drawn over the photograph, and what a finger can take hold of" -- which was not in it. They stayed behind because all three are positioned against `shown-*`, the fitted image rect the develop view derives because Slint does not report it. That is now the interface rather than the obstacle: each overlay is *given* that rect as its own bounds, so every position inside is a plain fraction of `root.width`, and none of them reaches out to `canvas-area` for an origin any more. GradientHandles joins MaskPanel in masks.slint and SpotHandles joins SpotPanel in spots.slint, each file now holding one domain's panel and its canvas overlay together, matching masks_ui.rs and spots_ui.rs. CropOverlay gets crop.slint of its own rather than growing adjust.slint. Arithmetic is unchanged: the old fraction-x subtracted shown-x from a coordinate measured relative to canvas-area, and the new one measures from an origin that already is shown-x. The handles stay unconditional rather than gaining an emptiness guard, so the repeater identity that spots_ui::sync_handles warns about is untouched. app.slint: 2834 -> 2490 lines. Verified by running the desktop app on a photograph, with the crop overlay's guard temporarily forced open so all three instantiate -- no binding loop, no panic. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
593 lines
24 KiB
Plaintext
593 lines
24 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 { Button, Caption, IconButton, Label, PanelHeading, Value } from "widgets.slint";
|
||
import { Segmented, SliderRow } from "controls.slint";
|
||
|
||
/// One layer in the stack.
|
||
export struct MaskRow {
|
||
// Identity, opaque to this file — the callbacks hand it straight back.
|
||
id: string,
|
||
label: string,
|
||
/// What kind of mask it is, for the row's second line.
|
||
kind: string,
|
||
enabled: bool,
|
||
/// The layer the develop panel is currently editing.
|
||
selected: bool,
|
||
inverted: bool,
|
||
opacity: float,
|
||
/// Edge transition half-width, as a fraction of the frame's shorter edge.
|
||
feather: float,
|
||
/// Index into `Falloff::ALL`.
|
||
falloff: int,
|
||
/// Index into `Morphology::ALL`.
|
||
morphology: int,
|
||
morph-radius: float,
|
||
/// Whether the edge controls apply. A gradient has its own geometry and
|
||
/// does not go through the distance field, so showing them would offer
|
||
/// controls that do nothing.
|
||
shapeable: bool,
|
||
/// Its region ids came from a different segmentation than the one loaded,
|
||
/// so applying it would produce a confidently wrong shape.
|
||
stale: bool,
|
||
/// Whether it currently changes any pixel. A bare selection does not, and
|
||
/// saying so is what stops it reading as broken.
|
||
adjusted: bool,
|
||
}
|
||
|
||
/// Which part of a gradient a canvas handle drags.
|
||
///
|
||
/// Named by the job rather than by the field, because the two gradients keep
|
||
/// different things in the same place: `edge` is a linear ramp's width and a
|
||
/// radial's major semi-axis, and `cross` is the radial's minor one.
|
||
export enum HandleRole { centre, edge, cross, rotate }
|
||
|
||
/// One draggable point on the photograph.
|
||
///
|
||
/// Positioned in fractions of the image **as it is currently shown** — after
|
||
/// the crop, the zoom and the pan — because that is the only space this file
|
||
/// can draw in. Rust maps the mask's own source-space geometry into it on
|
||
/// every frame, so the handle sits on the mask rather than beside it.
|
||
export struct GradientHandle {
|
||
role: HandleRole,
|
||
x: float,
|
||
y: float,
|
||
}
|
||
|
||
/// Something the model found.
|
||
export struct SubjectRow {
|
||
index: int,
|
||
label: string,
|
||
/// 0..1. Shown rather than hidden: the detector is a shortcut, not an
|
||
/// authority, and a weak guess presented like a strong one makes the tool
|
||
/// look wrong when it was merely unsure.
|
||
score: float,
|
||
}
|
||
|
||
component MaskEntry inherits Rectangle {
|
||
in property <MaskRow> data;
|
||
in property <bool> enabled: true;
|
||
/// Whether a refine pass is running (for any row — only one runs at a
|
||
/// time), so this row's button can say so and refuse a second press.
|
||
in property <bool> refining: false;
|
||
|
||
/// `true` when the click should add this row to the selection rather
|
||
/// than replace it with just this row.
|
||
callback selected(bool);
|
||
callback removed();
|
||
callback toggled(bool);
|
||
callback invert-toggled(bool);
|
||
callback opacity-changed(float);
|
||
callback feather-changed(float);
|
||
callback falloff-picked(int);
|
||
callback morphology-picked(int);
|
||
callback morph-radius-changed(float);
|
||
callback refined();
|
||
|
||
height: layout.preferred-height;
|
||
background: root.data.selected ? Theme.surface-raised : transparent;
|
||
border-radius: Theme.radius;
|
||
|
||
touch := TouchArea {
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
// `clicked` carries no modifiers, so a plain click and a
|
||
// multi-select click need the pointer event itself: released, over
|
||
// the row, with control or command held is "add to the selection"
|
||
// rather than "replace it".
|
||
pointer-event(event) => {
|
||
if event.kind == PointerEventKind.up && event.button == PointerEventButton.left {
|
||
root.selected(event.modifiers.control || event.modifiers.meta);
|
||
}
|
||
}
|
||
}
|
||
|
||
layout := VerticalLayout {
|
||
padding: Theme.gap-sm;
|
||
spacing: 2px;
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
// The eye. Disabling a layer is the A/B a local edit constantly
|
||
// wants, so it is one press away rather than inside the row.
|
||
visibility := TouchArea {
|
||
width: Theme.touch-target;
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.toggled(!root.data.enabled); }
|
||
|
||
Label {
|
||
text: root.data.enabled ? "◉" : "○";
|
||
emphasised: visibility.has-hover || root.data.enabled;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
VerticalLayout {
|
||
horizontal-stretch: 1;
|
||
alignment: center;
|
||
spacing: 0px;
|
||
|
||
Label {
|
||
text: root.data.label;
|
||
emphasised: root.data.selected || touch.has-hover;
|
||
overflow: elide;
|
||
}
|
||
|
||
Caption {
|
||
// Elided rather than wrapped: this sits beside two fixed
|
||
// targets in a row, and wrapping would give the rows of a
|
||
// stack different heights for no gain.
|
||
overflow: elide;
|
||
// Three states worth distinguishing, and each has a
|
||
// different remedy: stale needs the segmentation re-run,
|
||
// unadjusted needs a slider moved, and the ordinary case
|
||
// needs nothing said about it beyond what it is.
|
||
text: root.data.stale
|
||
? "needs re-segmenting"
|
||
: (root.data.adjusted
|
||
? root.data.kind
|
||
: root.data.kind + " · no adjustment yet");
|
||
}
|
||
}
|
||
|
||
remove := TouchArea {
|
||
width: Theme.touch-target;
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
enabled: root.enabled;
|
||
clicked => { root.removed(); }
|
||
|
||
Label {
|
||
text: "×";
|
||
emphasised: remove.has-hover;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Opacity and invert appear only on the selected layer. On every row
|
||
// they would triple the height of a stack of eight and put seven sets
|
||
// of controls in front of the one being worked on.
|
||
if root.data.selected: VerticalLayout {
|
||
spacing: Theme.gap-sm;
|
||
padding-top: Theme.gap-sm;
|
||
|
||
SliderRow {
|
||
label: "Opacity";
|
||
value: root.data.opacity;
|
||
default-value: 1.0;
|
||
minimum: 0.0;
|
||
maximum: 1.0;
|
||
changed(v) => { root.opacity-changed(v); }
|
||
reset => { root.opacity-changed(1.0); }
|
||
}
|
||
|
||
invert := TouchArea {
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
clicked => { root.invert-toggled(!root.data.inverted); }
|
||
|
||
Label {
|
||
text: root.data.inverted ? "Inverted — edit applies outside" : "Invert";
|
||
emphasised: invert.has-hover || root.data.inverted;
|
||
}
|
||
}
|
||
|
||
// 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(); }
|
||
}
|
||
|
||
// The edge. All of it reads one signed distance field, so these
|
||
// are free to move — none of them recomputes anything except a
|
||
// compound morphology.
|
||
if root.data.shapeable: SliderRow {
|
||
label: "Feather";
|
||
hint: "How far the edit fades across the edge.";
|
||
value: root.data.feather * 1000;
|
||
default-value: 4;
|
||
minimum: 0;
|
||
maximum: 60;
|
||
changed(v) => { root.feather-changed(v / 1000); }
|
||
reset => { root.feather-changed(0.004); }
|
||
}
|
||
|
||
if root.data.shapeable: Segmented {
|
||
label: "Falloff";
|
||
options: ["Hard", "Linear", "Smooth", "Gaussian", "Expo"];
|
||
selected: root.data.falloff;
|
||
picked(i) => { root.falloff-picked(i); }
|
||
}
|
||
|
||
if root.data.shapeable: Segmented {
|
||
label: "Edge";
|
||
hint: "Grow or shrink the outline the model drew. Close fills "
|
||
+ "pinholes; open removes specks.";
|
||
options: ["None", "Grow", "Shrink", "Close", "Open"];
|
||
selected: root.data.morphology;
|
||
picked(i) => { root.morphology-picked(i); }
|
||
}
|
||
|
||
if root.data.shapeable && root.data.morphology != 0: SliderRow {
|
||
label: "Amount";
|
||
value: root.data.morph-radius * 1000;
|
||
default-value: 0;
|
||
minimum: 0;
|
||
maximum: 40;
|
||
changed(v) => { root.morph-radius-changed(v / 1000); }
|
||
reset => { root.morph-radius-changed(0); }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export component MaskPanel inherits Rectangle {
|
||
in property <bool> enabled: true;
|
||
in property <[MaskRow]> masks;
|
||
in property <[SubjectRow]> subjects;
|
||
|
||
/// A region map has been computed for this image.
|
||
in property <bool> segmented: false;
|
||
/// One is being computed now.
|
||
in property <bool> segmenting: false;
|
||
/// A refine pass is running for one subject layer.
|
||
in property <bool> refining: false;
|
||
|
||
/// Whether the photographer has hidden the overlay to see the photograph.
|
||
/// Two-way bound to the window's own, so the canvas and this control never
|
||
/// disagree about it.
|
||
in-out property <bool> overlay-hidden: false;
|
||
|
||
|
||
/// `true` asks for the slower, tiled pass.
|
||
callback segment(bool);
|
||
|
||
/// `true` when the click should add the layer to the selection rather
|
||
/// than replace it.
|
||
callback mask-selected(string, bool);
|
||
callback mask-removed(string);
|
||
callback mask-refined(string);
|
||
callback mask-toggled(string, bool);
|
||
callback mask-invert-toggled(string, bool);
|
||
callback mask-opacity-changed(string, float);
|
||
callback mask-feather-changed(string, float);
|
||
callback mask-falloff-picked(string, int);
|
||
callback mask-morphology-picked(string, int);
|
||
callback mask-morph-radius-changed(string, float);
|
||
|
||
callback add-gradient(bool);
|
||
callback add-subject(int);
|
||
|
||
background: Theme.surface;
|
||
|
||
// **Flat, deliberately.** An earlier version grouped each state into its
|
||
// own `if ...: VerticalLayout { ... }`. The nested layout under-reported
|
||
// its height, so the divider and the mask row below it were placed over
|
||
// the "Find regions" button — the sort of fault that is invisible in the
|
||
// source and obvious the moment anyone looks at the panel. Conditioning
|
||
// each child on its own keeps every element a direct child of one layout,
|
||
/// TRACES: FR-UI-2
|
||
/// How wide this panel has to be before it starts clipping itself. The
|
||
/// develop column is the largest of these and nothing else; publishing it
|
||
/// as `min-width` too is what makes the enclosing layout aggregate them
|
||
/// without this panel having to be referenced by name.
|
||
out property <length> content-width: layout.preferred-width;
|
||
min-width: root.content-width;
|
||
|
||
// which is the layout that measures them.
|
||
layout := VerticalLayout {
|
||
padding: Theme.gap;
|
||
spacing: Theme.gap-sm;
|
||
alignment: start;
|
||
|
||
HorizontalLayout {
|
||
PanelHeading { text: "MASKS"; }
|
||
Rectangle { horizontal-stretch: 1; }
|
||
if root.segmented: Value { text: root.subjects.length + (root.subjects.length == 1 ? " subject" : " subjects");
|
||
}
|
||
}
|
||
|
||
if !root.enabled: Caption { text: "No image"; }
|
||
|
||
// --- the region map ---------------------------------------------
|
||
//
|
||
// Segmentation is a thing the user starts. Half a second of watershed
|
||
// and inference is too long to spend on every photograph opened, and
|
||
// most edits never need one.
|
||
if root.enabled && !root.segmented: Caption {
|
||
text: "Find the subjects in this photograph, so a mask can follow one.";
|
||
// Wrapped, or this sentence's single-line width becomes the
|
||
// panel's minimum and levers the fixed 280px develop column open,
|
||
// taking every other panel's controls off the right edge with it.
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if root.enabled && !root.segmented: Button {
|
||
text: root.segmenting ? "Looking…" : "Find subjects";
|
||
enabled: !root.segmenting;
|
||
primary: true;
|
||
clicked => { root.segment(false); }
|
||
}
|
||
|
||
// No "Overlay" button and no "Select" button. Both switched on things
|
||
// that are now simply what local mode *is*: entering it draws the
|
||
// region map and makes a click on the photograph mean "select". A mode
|
||
// whose behaviour has to be armed separately is a mode that can be
|
||
// entered and still do nothing, which is what these two allowed.
|
||
if root.enabled && root.segmented: Caption {
|
||
text: "Click a subject in the photograph to mask it.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
// Hiding the overlay is not the "Overlay" button this panel used to
|
||
// have, and the distinction is the reason it is back. That one *armed*
|
||
// the overlay: local mode could be entered and still show nothing,
|
||
// which is the fault the comment below records. This one only takes an
|
||
// overlay that is already there out of the way for a moment, which is
|
||
// the one thing a photographer needs constantly and had no way to do —
|
||
// a mask is judged against the photograph under it, and you cannot see
|
||
// that photograph through the thing describing it.
|
||
//
|
||
// Reads as its own action rather than its state: "Show the mask" is
|
||
// what pressing it will do, not what is currently true.
|
||
if root.enabled && root.segmented: Button {
|
||
text: root.overlay-hidden ? "Show the mask" : "Hide the mask";
|
||
clicked => { root.overlay-hidden = !root.overlay-hidden; }
|
||
}
|
||
|
||
// The second pass, offered rather than taken automatically.
|
||
//
|
||
// The model's input is a fixed 640x640 square and every frame is
|
||
// letterboxed into it, so a subject that is small in the frame reaches
|
||
// the model small — a bird at 200px in a 1600px proxy arrives at 80px,
|
||
// and an outline traced at 80px is what a coarse mask is. Tiling runs
|
||
// the model over overlapping windows instead, so that bird arrives at
|
||
// its own size.
|
||
//
|
||
// It costs one inference per tile: about 2.8s for a 3x2 grid against
|
||
// 470ms for the whole frame. That is the wrong trade for the usual
|
||
// photograph, where the subject fills much of the frame and the first
|
||
// pass is already the best answer available — so it is a button and
|
||
// not a default, and it says what it costs.
|
||
if root.enabled && root.segmented: Button {
|
||
text: root.segmenting ? "Looking closer…" : "Look closer (slower)";
|
||
enabled: !root.segmenting;
|
||
clicked => { root.segment(true); }
|
||
}
|
||
|
||
// Granularity, labelled by what it does rather than by its number:
|
||
// "detail" is what a photographer is choosing between, where "300
|
||
// regions" is an implementation detail they would have to learn.
|
||
// --- what the model found ----------------------------------------
|
||
if root.enabled && root.segmented && root.subjects.length > 0: Caption {
|
||
// Says where these came from and how they differ from a region.
|
||
// A list of four beside a count of three thousand invites exactly
|
||
// one question, and the panel should answer it rather than
|
||
// provoke it.
|
||
text: "Click one to mask it. The outline is the model's, so soften or grow it below.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
if root.enabled && root.segmented: VerticalLayout {
|
||
spacing: 0px;
|
||
for subject in root.subjects: subject-row := TouchArea {
|
||
height: Theme.touch-target;
|
||
mouse-cursor: pointer;
|
||
clicked => { root.add-subject(subject.index); }
|
||
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Label {
|
||
text: subject.label;
|
||
emphasised: subject-row.has-hover;
|
||
horizontal-stretch: 1;
|
||
overflow: elide;
|
||
}
|
||
Value { text: round(subject.score * 100) + "%"; }
|
||
}
|
||
}
|
||
}
|
||
|
||
if root.enabled && root.segmented && root.subjects.length == 0: Caption {
|
||
text: "Nothing recognised. The model knows people, animals and vehicles — "
|
||
+ "a landscape has no subject for it to find. Add a gradient instead.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Rectangle {
|
||
height: 1px;
|
||
background: Theme.rule;
|
||
}
|
||
|
||
// --- the stack -----------------------------------------------------
|
||
Caption { text: "Masks"; }
|
||
|
||
// Gradients need no segmentation, so they are offered whether or not
|
||
// one has been computed — a graduated filter over a sky is a local
|
||
// adjustment that never needed to know what a sky is.
|
||
HorizontalLayout {
|
||
spacing: Theme.gap-sm;
|
||
|
||
Button {
|
||
text: "Linear";
|
||
enabled: root.enabled;
|
||
clicked => { root.add-gradient(false); }
|
||
}
|
||
Button {
|
||
text: "Radial";
|
||
enabled: root.enabled;
|
||
clicked => { root.add-gradient(true); }
|
||
}
|
||
}
|
||
|
||
if root.enabled && root.masks.length == 0: Caption {
|
||
text: "No local adjustments. Select a region, or add a gradient.";
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
for mask in root.masks: MaskEntry {
|
||
data: mask;
|
||
enabled: root.enabled;
|
||
refining: root.refining;
|
||
selected(extend) => { root.mask-selected(mask.id, extend); }
|
||
removed => { root.mask-removed(mask.id); }
|
||
refined => { root.mask-refined(mask.id); }
|
||
toggled(on) => { root.mask-toggled(mask.id, on); }
|
||
invert-toggled(on) => { root.mask-invert-toggled(mask.id, on); }
|
||
opacity-changed(v) => { root.mask-opacity-changed(mask.id, v); }
|
||
feather-changed(v) => { root.mask-feather-changed(mask.id, v); }
|
||
falloff-picked(i) => { root.mask-falloff-picked(mask.id, i); }
|
||
morphology-picked(i) => { root.mask-morphology-picked(mask.id, i); }
|
||
morph-radius-changed(v) => { root.mask-morph-radius-changed(mask.id, v); }
|
||
}
|
||
|
||
// No caption saying which chain the sliders below are pointed at.
|
||
//
|
||
// That sentence used to be the *only* indication that selecting a
|
||
// layer had silently re-scoped thirty controls, and describing a
|
||
// hazard in a caption is not the same as removing it. The adjust
|
||
// panel's own heading names the layer it is editing now, which puts
|
||
// the answer on the thing that changed rather than above it.
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|