Send each canvas overlay home to its own domain

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>
This commit is contained in:
2026-08-27 21:35:08 +02:00
co-authored by Claude Opus 5
parent cafa63ca6f
commit 4fa914cbb1
5 changed files with 488 additions and 406 deletions
+104
View File
@@ -486,3 +486,107 @@ export component MaskPanel inherits Rectangle {
// 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),
);
}
}
}
}
}