diff --git a/core/dr-pipeline/src/framing.rs b/core/dr-pipeline/src/framing.rs index 475ff76..675a173 100644 --- a/core/dr-pipeline/src/framing.rs +++ b/core/dr-pipeline/src/framing.rs @@ -260,8 +260,9 @@ impl CropRect { /// /// `anchor` is the point of the rect that stays put, in the rect's own /// `0..1` coordinates: `(1.0, 1.0)` while the top-left handle is dragged, - /// so the far corner is the one that does not move, and `(0.5, 0.5)` when - /// a ratio is chosen and the composition should stay where it is. + /// so the far corner is the one that does not move, `(0.0, 0.5)` while + /// the right-hand edge is dragged, and `(0.5, 0.5)` when a ratio is + /// chosen and the composition should stay where it is. /// /// **The rect grows onto the ratio rather than shrinking onto it.** The /// axis that is short is extended; the long one is never trimmed. Fitting @@ -269,6 +270,7 @@ impl CropRect { /// along one axis alone would be immediately clamped back by the other, /// and the handle would simply refuse to move. The result is then scaled /// down, both axes together, only as far as the frame's edge demands. + /// The exception is an edge: see the note in the body. pub fn with_aspect(self, frame_w: u32, frame_h: u32, ratio: f32, anchor: (f32, f32)) -> Self { let rect = self.normalised(); let ratio = finite(ratio, 0.0); @@ -286,8 +288,19 @@ impl CropRect { let px = rect.x + ax * rect.width; let py = rect.y + ay * rect.height; - let mut w = rect.width.max(rect.height * r); - let mut h = w / r; + // An anchor in the middle of one side is an *edge* being dragged, and + // then the axis across that edge leads: it is the only one the user + // moved. Growing the short axis instead would take the other side + // for the leader whenever the edge went inward, and the edge would be + // pushed straight back out — a handle that only ever grows the crop. + let (mut w, mut h) = if ax == 0.5 && ay != 0.5 { + (rect.height * r, rect.height) + } else if ay == 0.5 && ax != 0.5 { + (rect.width, rect.width / r) + } else { + let w = rect.width.max(rect.height * r); + (w, w / r) + }; // Scaled to fit, never clamped to fit: clamping one axis against the // frame would break the very ratio this exists to hold. @@ -2255,6 +2268,42 @@ mod tests { assert!((c.y - start.y).abs() < 1e-5, "{c:?}"); } + #[test] + fn a_locked_edge_leads_and_the_far_side_stays_put() { + // An edge dragged inward under a lock must narrow the crop. With the + // short axis leading, the untouched height would win and push the + // edge straight back out. + let start = CropRect { + x: 0.2, + y: 0.2, + width: 0.4, + height: 0.6, + }; + // Right edge held, dragged in: the left side and the vertical + // centre stay, the width is what was asked for. + let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.5)); + assert!((c.x - start.x).abs() < 1e-5, "{c:?}"); + assert!((c.width - start.width).abs() < 1e-5, "{c:?}"); + assert!((c.height - start.width).abs() < 1e-5, "{c:?}"); + assert!( + (c.y + c.height / 2.0 - (start.y + start.height / 2.0)).abs() < 1e-5, + "{c:?}" + ); + + // Top edge held: the bottom and the horizontal centre stay, the + // height is what was asked for. + let c = start.with_aspect(4000, 4000, 1.0, (0.5, 1.0)); + assert!( + (c.y + c.height - (start.y + start.height)).abs() < 1e-5, + "{c:?}" + ); + assert!((c.width - c.height).abs() < 1e-5, "{c:?}"); + assert!( + (c.x + c.width / 2.0 - (start.x + start.width / 2.0)).abs() < 1e-5, + "{c:?}" + ); + } + #[test] fn a_locked_rect_grows_onto_the_ratio_rather_than_shrinking_onto_it() { // Shrinking to fit makes a one-axis drag do nothing at all: the other diff --git a/ui/dr-ui/src/develop_ui.rs b/ui/dr-ui/src/develop_ui.rs index 458b674..4c8cf06 100644 --- a/ui/dr-ui/src/develop_ui.rs +++ b/ui/dr-ui/src/develop_ui.rs @@ -1042,9 +1042,10 @@ fn wire_zoom_pan_crop( width, height, }; - // The overlay reports the corner it is *holding*; the point - // that must not move is the opposite one. A move reports no - // corner at all, and keeps the shape it already has — there is + // The overlay reports the handle it is *holding*; the point + // that must not move is the opposite one — the far corner, or + // for an edge the middle of the far side. A move reports no + // handle at all, and keeps the shape it already has — there is // nothing to reshape, and reshaping about a centre would drag // an over-moved rect smaller instead of sliding it along the // edge. diff --git a/ui/dr-ui/ui/crop.slint b/ui/dr-ui/ui/crop.slint index 0e0852e..d902834 100644 --- a/ui/dr-ui/ui/crop.slint +++ b/ui/dr-ui/ui/crop.slint @@ -37,15 +37,17 @@ export component CropOverlay inherits Rectangle { in property crop-w: 1.0; in property crop-h: 1.0; - /// A dragged crop rect — `x`, `y`, `width`, `height`, then the corner the - /// drag is holding as a `0`/`1` pair, or `-1, -1` where the whole rect is - /// being moved and its shape must not be touched. + /// A dragged crop rect — `x`, `y`, `width`, `height`, then the handle the + /// drag is holding as a point of the rect: a `0`/`1` pair for a corner, + /// `0.5` on the axis an edge does not move, or `-1, -1` where the whole + /// rect is being moved and its shape must not be touched. /// - /// The corner is what an aspect lock needs and cannot infer: reshaping a - /// rect onto a ratio has to know which of its corners is nailed down, and + /// The handle is what an aspect lock needs and cannot infer: reshaping a + /// rect onto a ratio has to know which point of it is nailed down, and /// only the handle that took the press knows that. It is reported as the - /// held corner rather than as the fixed one because that is what is - /// written here — Rust takes the opposite corner. + /// held point rather than as the fixed one because that is what is + /// written here — Rust takes the opposite one, which for an edge is the + /// middle of the far side. callback crop-changed(float, float, float, float, float, float); /// TRACES: FR-DEV-17 @@ -189,24 +191,43 @@ export component CropOverlay inherits Rectangle { } } - // 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 [ + // Handles: the four edges, then the four corners. + // + // A corner drags one corner while the opposite stays put, which is the + // only behaviour that lets a crop be shaped rather than merely moved. An + // edge drags that one side along the axis across it and leaves the other + // three where they are — the way to trim a strip off one side without + // disturbing the corner you had already placed. + // + // Each is a point of the rect in its own `0..1` coordinates; `0.5` on an + // axis is an edge's midpoint and means "this axis does not move". The + // edges come first because among overlapping siblings the last declared + // takes the press, and on a rect dragged small the corners are the ones + // that must stay reachable. + for handle in [ + { hx: 0.5, hy: 0.0 }, + { hx: 0.5, hy: 1.0 }, + { hx: 0.0, hy: 0.5 }, + { hx: 1.0, hy: 0.5 }, { 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 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; + property across: handle.hx == 0.5; + property along: handle.hy == 0.5; + // A corner is a square; an edge is a bar lying along its side, with + // a hit area as long as the bar and as deep as a corner's. + property grip: 18px; + property reach: 36px; + width: self.across ? self.reach : self.grip; + height: self.along ? self.reach : self.grip; + x: parent.rx + handle.hx * parent.rw - self.width / 2; + y: parent.ry + handle.hy * parent.rh - self.height / 2; Rectangle { - width: 12px; - height: 12px; + width: parent.across ? 24px : (parent.along ? 5px : 12px); + height: parent.along ? 24px : (parent.across ? 5px : 12px); background: #ffffff; border-radius: 2px; } @@ -214,9 +235,13 @@ export component CropOverlay inherits Rectangle { TouchArea { width: 100%; height: 100%; - mouse-cursor: (corner.hx == corner.hy) - ? MouseCursor.nwse-resize - : MouseCursor.nesw-resize; + mouse-cursor: parent.across + ? MouseCursor.ns-resize + : parent.along + ? MouseCursor.ew-resize + : (handle.hx == handle.hy) + ? MouseCursor.nwse-resize + : MouseCursor.nesw-resize; property ox; property oy; @@ -246,27 +271,27 @@ export component CropOverlay inherits Rectangle { } } - // Movement as a fraction of the frame, live while - // the handle is held. - property dx: self.fraction-x(self.mouse-x) - self.from-x; - property dy: self.fraction-y(self.mouse-y) - self.from-y; + // Movement as a fraction of the frame, live while the handle is + // held — and nothing along an axis the handle does not move, so + // an edge dragged at a slant still only moves its own side. + property dx: parent.across ? 0 : self.fraction-x(self.mouse-x) - self.from-x; + property dy: parent.along ? 0 : self.fraction-y(self.mouse-y) - self.from-y; 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. + // 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, - corner.hx, - corner.hy, + handle.hx == 0 ? self.ox + self.dx : self.ox, + handle.hy == 0 ? self.oy + self.dy : self.oy, + handle.hx == 0 ? self.ow - self.dx : self.ow + self.dx, + handle.hy == 0 ? self.oh - self.dy : self.oh + self.dy, + handle.hx, + handle.hy, ); } }