Let a crop be trimmed from one edge

Four bar handles at the midpoints of the sides, each moving only its
own side along the axis across it. The overlay reports an edge as 0.5
on the axis it does not move, so the anchor Rust takes is the middle
of the far side.

Under a ratio lock the dragged axis leads. with_aspect grew the short
axis onto the ratio, which for an edge pulled inward made the untouched
axis the leader and pushed the edge straight back out.
This commit is contained in:
2026-09-29 21:31:27 -04:00
parent 4da2ec39b3
commit 1bc04870c3
3 changed files with 118 additions and 43 deletions
+53 -4
View File
@@ -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
+4 -3
View File
@@ -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.
+61 -36
View File
@@ -37,15 +37,17 @@ export component CropOverlay inherits Rectangle {
in property <float> crop-w: 1.0;
in property <float> 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 <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;
property <bool> across: handle.hx == 0.5;
property <bool> 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 <length> grip: 18px;
property <length> 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 <float> ox;
property <float> oy;
@@ -246,27 +271,27 @@ export component CropOverlay inherits Rectangle {
}
}
// Movement as a fraction of the frame, live while
// the handle is held.
property <float> dx: self.fraction-x(self.mouse-x) - self.from-x;
property <float> 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 <float> dx: parent.across ? 0 : self.fraction-x(self.mouse-x) - self.from-x;
property <float> 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,
);
}
}