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:
+43
-386
@@ -1,7 +1,8 @@
|
||||
import { Theme } from "theme.slint";
|
||||
import { AdjustPanel, GeometryPanel, ModeStrip, ParamRow, TransferPanel, ViewMode } from "adjust.slint";
|
||||
import { GradientHandle, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { SpotHandle, SpotPanel, SpotRole } from "spots.slint";
|
||||
import { GradientHandle, GradientHandles, HandleRole, MaskPanel, MaskRow, SubjectRow } from "masks.slint";
|
||||
import { SpotHandle, SpotHandles, SpotPanel, SpotRole } from "spots.slint";
|
||||
import { CropOverlay } from "crop.slint";
|
||||
import { HistoryPanel, HistoryRow } from "history.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
||||
@@ -1973,407 +1974,63 @@ in property <bool> panel-visible: true;
|
||||
|
||||
// --- crop overlay ------------------------------------------
|
||||
//
|
||||
// Four dimmed, desaturated panels around the crop, then the
|
||||
// rect itself with handles. Panels rather than one shape with
|
||||
// a hole: Slint has no cut-out, and four rectangles are exact.
|
||||
if root.cropping && root.total > 0 && root.load-error == "": crop-overlay := Rectangle {
|
||||
x: parent.shown-x;
|
||||
y: parent.shown-y;
|
||||
width: parent.shown-w;
|
||||
height: parent.shown-h;
|
||||
// Placed over the fitted image, so the fractions it draws are
|
||||
// fractions of the picture. See `CropOverlay` in crop.slint.
|
||||
if root.cropping && root.total > 0 && root.load-error == "": CropOverlay {
|
||||
x: canvas-area.shown-x;
|
||||
y: canvas-area.shown-y;
|
||||
width: canvas-area.shown-w;
|
||||
height: canvas-area.shown-h;
|
||||
|
||||
property <length> rx: root.crop-x * self.width;
|
||||
property <length> ry: root.crop-y * self.height;
|
||||
property <length> rw: root.crop-w * self.width;
|
||||
property <length> rh: root.crop-h * self.height;
|
||||
crop-x: root.crop-x;
|
||||
crop-y: root.crop-y;
|
||||
crop-w: root.crop-w;
|
||||
crop-h: root.crop-h;
|
||||
|
||||
// The surround: dimmed and drained of colour, so the crop
|
||||
// reads as the photograph and everything else as context
|
||||
// being discarded. Slint cannot desaturate a live image,
|
||||
// so this is a heavy neutral wash over it — the dimming
|
||||
// carries the separation and the neutrality kills the
|
||||
// colour cue.
|
||||
property <brush> veil: #20242aE0;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: crop-overlay.ry;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: crop-overlay.ry + crop-overlay.rh;
|
||||
width: parent.width;
|
||||
height: parent.height - crop-overlay.ry - crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: crop-overlay.ry;
|
||||
width: crop-overlay.rx;
|
||||
height: crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: crop-overlay.rx + crop-overlay.rw;
|
||||
y: crop-overlay.ry;
|
||||
width: parent.width - crop-overlay.rx - crop-overlay.rw;
|
||||
height: crop-overlay.rh;
|
||||
background: crop-overlay.veil;
|
||||
}
|
||||
|
||||
// The rect, its thirds, and the handles.
|
||||
Rectangle {
|
||||
x: parent.rx;
|
||||
y: parent.ry;
|
||||
width: parent.rw;
|
||||
height: parent.rh;
|
||||
border-width: 1px;
|
||||
border-color: #ffffffCC;
|
||||
|
||||
// Rule of thirds, the reason a crop overlay is worth
|
||||
// drawing at all rather than typing numbers.
|
||||
Rectangle {
|
||||
x: parent.width / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width * 2 / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height * 2 / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
|
||||
// Drag the whole rect.
|
||||
move-area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: MouseCursor.move;
|
||||
|
||||
property <float> start-x;
|
||||
property <float> start-y;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.start-x = root.crop-x;
|
||||
self.start-y = root.crop-y;
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed) {
|
||||
root.crop-changed(
|
||||
self.start-x
|
||||
+ (self.mouse-x - self.pressed-x)
|
||||
/ max(crop-overlay.width, 1px),
|
||||
self.start-y
|
||||
+ (self.mouse-y - self.pressed-y)
|
||||
/ max(crop-overlay.height, 1px),
|
||||
root.crop-w,
|
||||
root.crop-h,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corner handles. Each drags one corner while the opposite
|
||||
// stays put, which is the only behaviour that lets a crop
|
||||
// be shaped rather than merely moved.
|
||||
for corner in [
|
||||
{ hx: 0.0, hy: 0.0 },
|
||||
{ hx: 1.0, hy: 0.0 },
|
||||
{ hx: 0.0, hy: 1.0 },
|
||||
{ hx: 1.0, hy: 1.0 },
|
||||
]: Rectangle {
|
||||
property <length> size: 18px;
|
||||
x: parent.rx + corner.hx * parent.rw - self.size / 2;
|
||||
y: parent.ry + corner.hy * parent.rh - self.size / 2;
|
||||
width: self.size;
|
||||
height: self.size;
|
||||
|
||||
Rectangle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: (corner.hx == corner.hy)
|
||||
? MouseCursor.nwse-resize
|
||||
: MouseCursor.nesw-resize;
|
||||
|
||||
property <float> ox;
|
||||
property <float> oy;
|
||||
property <float> ow;
|
||||
property <float> oh;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.ox = root.crop-x;
|
||||
self.oy = root.crop-y;
|
||||
self.ow = root.crop-w;
|
||||
self.oh = root.crop-h;
|
||||
}
|
||||
}
|
||||
|
||||
// Movement as a fraction of the frame, live while
|
||||
// the handle is held.
|
||||
property <float> dx:
|
||||
(self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px);
|
||||
property <float> dy:
|
||||
(self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px);
|
||||
|
||||
moved => {
|
||||
if (!self.pressed) {
|
||||
return;
|
||||
}
|
||||
// Dragging a left/top handle moves the origin
|
||||
// and shrinks the extent by the same amount;
|
||||
// a right/bottom handle moves only the extent.
|
||||
// Rust clamps the result, so an over-drag
|
||||
// slides rather than inverting.
|
||||
root.crop-changed(
|
||||
corner.hx == 0 ? self.ox + self.dx : self.ox,
|
||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
crop-changed(x, y, w, h) => { root.crop-changed(x, y, w, h); }
|
||||
}
|
||||
|
||||
// --- gradient handles ---------------------------------------
|
||||
//
|
||||
// TRACES: FR-DEV-3 | FR-UI-3
|
||||
// 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.
|
||||
//
|
||||
// **Positioned against `shown-*`, like the crop overlay
|
||||
// and the region picker.** The photograph is letterboxed
|
||||
// inside this box and Slint does not report the fitted
|
||||
// rect, so anything that has to land *on* the picture is
|
||||
// placed against the derived one. 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).
|
||||
for handle in root.gradient-handles: Rectangle {
|
||||
x: parent.shown-x + handle.x * parent.shown-w - self.width / 2;
|
||||
y: parent.shown-y + handle.y * parent.shown-h - self.height / 2;
|
||||
width: Theme.touch-target;
|
||||
height: Theme.touch-target;
|
||||
// Handed the fitted image rect for the same reason the crop
|
||||
// overlay and the repairs are: the photograph is letterboxed
|
||||
// inside this box, Slint does not report the fitted rect, and
|
||||
// anything that has to land *on* the picture is placed against
|
||||
// the derived one. See `GradientHandles` in masks.slint.
|
||||
GradientHandles {
|
||||
x: canvas-area.shown-x;
|
||||
y: canvas-area.shown-y;
|
||||
width: canvas-area.shown-w;
|
||||
height: canvas-area.shown-h;
|
||||
|
||||
// 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;
|
||||
handles: root.gradient-handles;
|
||||
|
||||
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 - canvas-area.shown-x)
|
||||
/ max(canvas-area.shown-w, 1px);
|
||||
}
|
||||
function fraction-y(local-y: length) -> float {
|
||||
return (parent.y + local-y - canvas-area.shown-y)
|
||||
/ max(canvas-area.shown-h, 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);
|
||||
}
|
||||
// One history step per gesture, not per frame.
|
||||
if (ev.kind == PointerEventKind.up) {
|
||||
root.gradient-handle-released();
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed) {
|
||||
root.gradient-handle-dragged(
|
||||
handle.role,
|
||||
self.from-x,
|
||||
self.from-y,
|
||||
self.fraction-x(self.mouse-x),
|
||||
self.fraction-y(self.mouse-y),
|
||||
);
|
||||
}
|
||||
}
|
||||
dragged(role, fx, fy, tx, ty) => {
|
||||
root.gradient-handle-dragged(role, fx, fy, tx, ty);
|
||||
}
|
||||
released() => { root.gradient-handle-released(); }
|
||||
}
|
||||
|
||||
// --- repairs (FR-DEV-8) -------------------------------------
|
||||
//
|
||||
// **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.
|
||||
//
|
||||
// Positioned against `shown-*` like everything else that
|
||||
// has to land on the picture, and mapped through the
|
||||
// framing on the Rust side, so a repair follows a crop, a
|
||||
// zoom and a rotation rather than sitting where it used to
|
||||
// be.
|
||||
for spot in root.spot-handles: Rectangle {
|
||||
property <length> disc: max(2 * spot.radius * parent.shown-h, 8px);
|
||||
property <bool> is-source: spot.role == SpotRole.source;
|
||||
// Every position in here is a fraction of the fitted image
|
||||
// rect, which is why the overlay is handed that rect rather
|
||||
// than the canvas box. See `SpotHandles` in spots.slint for
|
||||
// what is drawn and why it is drawn at true size.
|
||||
SpotHandles {
|
||||
x: canvas-area.shown-x;
|
||||
y: canvas-area.shown-y;
|
||||
width: canvas-area.shown-w;
|
||||
height: canvas-area.shown-h;
|
||||
|
||||
x: parent.shown-x + spot.x * parent.shown-w - self.width / 2;
|
||||
y: parent.shown-y + spot.y * parent.shown-h - 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;
|
||||
handles: root.spot-handles;
|
||||
|
||||
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 - canvas-area.shown-x)
|
||||
/ max(canvas-area.shown-w, 1px);
|
||||
}
|
||||
function fraction-y(local-y: length) -> float {
|
||||
return (parent.y + local-y - canvas-area.shown-y)
|
||||
/ max(canvas-area.shown-h, 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.spot-selected(spot.id);
|
||||
}
|
||||
if (ev.kind == PointerEventKind.up) {
|
||||
root.spot-handle-released();
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed) {
|
||||
root.spot-handle-dragged(
|
||||
spot.id,
|
||||
spot.role,
|
||||
self.from-x,
|
||||
self.from-y,
|
||||
self.fraction-x(self.mouse-x),
|
||||
self.fraction-y(self.mouse-y),
|
||||
);
|
||||
}
|
||||
}
|
||||
selected(id) => { root.spot-selected(id); }
|
||||
dragged(id, role, fx, fy, tx, ty) => {
|
||||
root.spot-handle-dragged(id, role, fx, fy, tx, ty);
|
||||
}
|
||||
released() => { root.spot-handle-released(); }
|
||||
}
|
||||
|
||||
// Arrow keys and space step through the folder.
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
// TRACES: FR-DEV-4
|
||||
// The crop rectangle drawn on the photograph.
|
||||
//
|
||||
// Four dimmed, desaturated panels around the crop, then the rect itself with
|
||||
// handles. Panels rather than one shape with a hole: Slint has no cut-out, and
|
||||
// four rectangles are exact.
|
||||
//
|
||||
// Sized by the develop view to the fitted image rect, so the fractions below
|
||||
// are fractions of the picture and not of the canvas box it is letterboxed
|
||||
// inside. The rect itself is mirrored from Rust rather than owned here, for
|
||||
// the reason `AppWindow` gives: Rust clamps and squares it, so the overlay
|
||||
// must draw what was actually applied and not what the drag asked for.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
|
||||
export component CropOverlay inherits Rectangle {
|
||||
/// The crop rect, in fractions of the frame.
|
||||
in property <float> crop-x: 0.0;
|
||||
in property <float> crop-y: 0.0;
|
||||
in property <float> crop-w: 1.0;
|
||||
in property <float> crop-h: 1.0;
|
||||
|
||||
/// A dragged crop rect, in the same fractions.
|
||||
callback crop-changed(float, float, float, float);
|
||||
|
||||
|
||||
property <length> rx: root.crop-x * self.width;
|
||||
property <length> ry: root.crop-y * self.height;
|
||||
property <length> rw: root.crop-w * self.width;
|
||||
property <length> rh: root.crop-h * self.height;
|
||||
|
||||
// The surround: dimmed and drained of colour, so the crop
|
||||
// reads as the photograph and everything else as context
|
||||
// being discarded. Slint cannot desaturate a live image,
|
||||
// so this is a heavy neutral wash over it — the dimming
|
||||
// carries the separation and the neutrality kills the
|
||||
// colour cue.
|
||||
property <brush> veil: #20242aE0;
|
||||
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: 0;
|
||||
width: parent.width;
|
||||
height: root.ry;
|
||||
background: root.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: root.ry + root.rh;
|
||||
width: parent.width;
|
||||
height: parent.height - root.ry - root.rh;
|
||||
background: root.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0;
|
||||
y: root.ry;
|
||||
width: root.rx;
|
||||
height: root.rh;
|
||||
background: root.veil;
|
||||
}
|
||||
Rectangle {
|
||||
x: root.rx + root.rw;
|
||||
y: root.ry;
|
||||
width: parent.width - root.rx - root.rw;
|
||||
height: root.rh;
|
||||
background: root.veil;
|
||||
}
|
||||
|
||||
// The rect, its thirds, and the handles.
|
||||
Rectangle {
|
||||
x: parent.rx;
|
||||
y: parent.ry;
|
||||
width: parent.rw;
|
||||
height: parent.rh;
|
||||
border-width: 1px;
|
||||
border-color: #ffffffCC;
|
||||
|
||||
// Rule of thirds, the reason a crop overlay is worth
|
||||
// drawing at all rather than typing numbers.
|
||||
Rectangle {
|
||||
x: parent.width / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: parent.width * 2 / 3;
|
||||
y: 0; width: 1px; height: parent.height;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
Rectangle {
|
||||
x: 0; y: parent.height * 2 / 3;
|
||||
width: parent.width; height: 1px;
|
||||
background: #ffffff44;
|
||||
}
|
||||
|
||||
// Drag the whole rect.
|
||||
move-area := TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: MouseCursor.move;
|
||||
|
||||
property <float> start-x;
|
||||
property <float> start-y;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.start-x = root.crop-x;
|
||||
self.start-y = root.crop-y;
|
||||
}
|
||||
}
|
||||
|
||||
moved => {
|
||||
if (self.pressed) {
|
||||
root.crop-changed(
|
||||
self.start-x
|
||||
+ (self.mouse-x - self.pressed-x)
|
||||
/ max(root.width, 1px),
|
||||
self.start-y
|
||||
+ (self.mouse-y - self.pressed-y)
|
||||
/ max(root.height, 1px),
|
||||
root.crop-w,
|
||||
root.crop-h,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Corner handles. Each drags one corner while the opposite
|
||||
// stays put, which is the only behaviour that lets a crop
|
||||
// be shaped rather than merely moved.
|
||||
for corner in [
|
||||
{ hx: 0.0, hy: 0.0 },
|
||||
{ hx: 1.0, hy: 0.0 },
|
||||
{ hx: 0.0, hy: 1.0 },
|
||||
{ hx: 1.0, hy: 1.0 },
|
||||
]: Rectangle {
|
||||
property <length> size: 18px;
|
||||
x: parent.rx + corner.hx * parent.rw - self.size / 2;
|
||||
y: parent.ry + corner.hy * parent.rh - self.size / 2;
|
||||
width: self.size;
|
||||
height: self.size;
|
||||
|
||||
Rectangle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
TouchArea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
mouse-cursor: (corner.hx == corner.hy)
|
||||
? MouseCursor.nwse-resize
|
||||
: MouseCursor.nesw-resize;
|
||||
|
||||
property <float> ox;
|
||||
property <float> oy;
|
||||
property <float> ow;
|
||||
property <float> oh;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.ox = root.crop-x;
|
||||
self.oy = root.crop-y;
|
||||
self.ow = root.crop-w;
|
||||
self.oh = root.crop-h;
|
||||
}
|
||||
}
|
||||
|
||||
// Movement as a fraction of the frame, live while
|
||||
// the handle is held.
|
||||
property <float> dx:
|
||||
(self.mouse-x - self.pressed-x) / max(root.width, 1px);
|
||||
property <float> dy:
|
||||
(self.mouse-y - self.pressed-y) / max(root.height, 1px);
|
||||
|
||||
moved => {
|
||||
if (!self.pressed) {
|
||||
return;
|
||||
}
|
||||
// Dragging a left/top handle moves the origin
|
||||
// and shrinks the extent by the same amount;
|
||||
// a right/bottom handle moves only the extent.
|
||||
// Rust clamps the result, so an over-drag
|
||||
// slides rather than inverting.
|
||||
root.crop-changed(
|
||||
corner.hx == 0 ? self.ox + self.dx : self.ox,
|
||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user