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
+119
View File
@@ -176,3 +176,122 @@ export component SpotPanel inherits Rectangle {
}
}
}
/// TRACES: FR-DEV-8
/// The repairs, drawn on the photograph.
///
/// **Drawn at the size they are.** A gradient's handles are dots because a
/// gradient has no edge to show; a repair is a disc, and whether that disc
/// covers a speck of dust is the entire judgement a photographer is making. A
/// fixed-size dot standing in for it would say nothing about the edit.
///
/// The source circle appears for the **selected** repair only. Every repair
/// showing both would double the circles on a dusty sky and leave no way to
/// tell which source belongs to which disc; Rust decides, and simply does not
/// send the others.
///
/// Sized by the develop view to the fitted image rect, so every position in
/// here is a plain fraction of `root.width` and `root.height`. The fractions
/// themselves arrive already mapped through the framing, so a repair follows a
/// crop, a zoom and a rotation rather than sitting where it used to be.
export component SpotHandles inherits Rectangle {
in property <[SpotHandle]> handles;
/// Touching a repair chooses it.
callback selected(string);
/// id, role, from-x, from-y, to-x, to-y — fractions of this rect.
callback dragged(string, SpotRole, float, float, float, float);
callback released();
background: transparent;
for spot in root.handles: Rectangle {
property <length> disc: max(2 * spot.radius * root.height, 8px);
property <bool> is-source: spot.role == SpotRole.source;
x: spot.x * root.width - self.width / 2;
y: spot.y * root.height - self.height / 2;
// Never smaller than a finger, whatever the repair's own size: a spot
// on a dust speck is a few pixels across at fit-to-window, and a
// target that small cannot be picked up again on a phone (FR-UI-3).
// The *drawn* circle keeps its true size; only the reach is padded.
width: max(self.disc, Theme.touch-target);
height: self.width;
Rectangle {
width: parent.disc;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: spot.selected ? 2px : 1px;
// White on a dark ring, so a repair is visible on a blown sky and
// on a black frame alike — the same treatment the gradient handles
// use, and no theme token, because this is drawn over the
// photograph rather than over the interface.
border-color: spot.enabled ? #ffffffcc : #ffffff55;
background: parent.is-source
? #ffffff14
: (spot.selected ? #ffffff22 : transparent);
}
// A second, darker ring just outside the first. One white circle
// vanishes against a white sky however it is drawn; two rings of
// opposite tone cannot both vanish against anything.
Rectangle {
width: parent.disc + 2px;
height: self.width;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: self.width / 2;
border-width: 1px;
border-color: #00000066;
}
grab := TouchArea {
width: 100%;
height: 100%;
mouse-cursor: MouseCursor.move;
// Where the press landed, in the fractions the callback reports
// in — captured on the way down so the 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);
// Touching a repair is choosing it. On the way *down*, so
// the column has re-scoped by the time the drag begins and
// the controls describe what is moving.
root.selected(spot.id);
}
if (ev.kind == PointerEventKind.up) {
root.released();
}
}
moved => {
if (self.pressed) {
root.dragged(
spot.id,
spot.role,
self.from-x,
self.from-y,
self.fraction-x(self.mouse-x),
self.fraction-y(self.mouse-y),
);
}
}
}
}
}