Let the crop be held to a ratio while it is dragged

A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
not choosing four edges — they are choosing one edge and a known shape.
Free-dragging every corner made them do that arithmetic by eye on every
drag, and get it slightly wrong.

The panel now offers Free, Original, 1:1, 3:2, 4:3 and 16:9, with a
Portrait switch for the ones that have two orientations. Original follows
the frame rather than naming a number, so it stays right on the next
photograph from another body and after a quarter turn.

**The ratio is of output pixels, and the rect is not.** `CropRect` is
stored in fractions of a frame that is not itself square, so holding a
shape needs the frame's size — `ratio * height / width` of the frame.
Skipping that gives a "1:1" crop that is square only on a square
photograph, which is the one case nobody would test on, so the conversion
lives in `CropRect::with_aspect` where it is explained and pinned by a
test that asserts the fractions are *not* equal.

Two decisions worth recording:

The reshaped rect **grows** onto the ratio rather than shrinking onto it,
then scales down only as far as the frame's edge demands. Fitting inside
instead makes a one-axis drag do nothing at all — the other axis clamps
the first straight back, and the handle simply refuses to move.

The overlay now reports **which corner the drag is holding**, because
reshaping onto a ratio has to know which corner is nailed down and only
the handle that took the press knows that. A move reports no corner and
keeps its shape: reshaping about a centre would pull an over-moved rect
smaller instead of sliding it along the edge.

The lock lives with the window rather than the session. A `DevelopSession`
is per image, and cropping a set of frames to one shape is exactly when
the lock earns its place. It is not an edit and reaches no sidecar — what
is saved is the rectangle it produced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:48:51 +02:00
co-authored by Claude Opus 5
parent dbaf5358d1
commit e6ad906bc1
7 changed files with 644 additions and 56 deletions
+147 -7
View File
@@ -359,6 +359,58 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
window.set_crop_h(crop.height);
}
/// TRACES: FR-DEV-3
/// Push the chosen crop ratio back to the panel.
///
/// The index is written from the choice actually held rather than from what
/// was clicked, for the same reason [`sync_framing`] writes the applied crop:
/// picking a ratio with no portrait form clears the orientation switch, and a
/// panel showing the click rather than the result would light a switch that is
/// doing nothing.
fn sync_crop_aspect(
window: &AppWindow,
aspect: &Rc<Cell<develop::CropAspect>>,
portrait: &Rc<Cell<bool>>,
) {
let chosen = aspect.get();
let index = develop::CropAspect::CHOICES
.iter()
.position(|a| *a == chosen)
.unwrap_or(0);
window.set_crop_aspect(index as i32);
window.set_crop_portrait(portrait.get());
window.set_crop_aspect_turnable(chosen.has_orientation());
}
/// TRACES: FR-DEV-3
/// Reshape the open image's crop onto the chosen ratio, about its centre.
///
/// Called when the ratio itself changes, never on opening a photograph: the
/// lock outlives the session it is applied to, and reshaping a crop the user
/// has not touched — on an image they have only just opened — would edit their
/// work as a side effect of stepping through a shoot. A ratio takes effect
/// when it is chosen and when a handle is dragged, both of which are things
/// the user did on purpose.
fn apply_crop_aspect(
window: &AppWindow,
session: &Rc<RefCell<Option<DevelopSession>>>,
aspect: &Rc<Cell<develop::CropAspect>>,
portrait: &Rc<Cell<bool>>,
) {
let Some(s) = session.borrow_mut().as_mut().map(|s| {
s.set_crop_locked(s.crop(), aspect.get(), portrait.get(), (0.5, 0.5));
(s.crop(), s.framing_edits_image())
}) else {
return;
};
let (crop, modified) = s;
window.set_crop_x(crop.x);
window.set_crop_y(crop.y);
window.set_crop_w(crop.width);
window.set_crop_h(crop.height);
window.set_framing_modified(modified);
}
/// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7
/// Render the open image at full resolution, ready to be handed to the worker.
///
@@ -2354,6 +2406,27 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
//
// Zoom and pan are viewing state and touch no parameter, so unlike the
// handlers above they do not `sync_rows`.
// TRACES: FR-DEV-3
// **The ratio lock belongs to the tool, not to the photograph.** A
// `DevelopSession` is built per image, so holding the lock there would
// reset it at every step through a shoot — and cropping a set of frames to
// one shape is precisely when the lock earns its place. It lives here, for
// the life of the window, and is re-applied to whichever session is open.
//
// It is deliberately *not* an edit. Nothing about it reaches the sidecar:
// what is saved is the rectangle it produced, which is the whole of what
// the pipeline needs and the whole of what another application could read.
let crop_aspect = Rc::new(Cell::new(develop::CropAspect::default()));
let crop_portrait = Rc::new(Cell::new(false));
window.set_crop_aspects(
develop::CropAspect::CHOICES
.iter()
.map(|a| slint::SharedString::from(a.label()))
.collect::<Vec<_>>()
.as_slice()
.into(),
);
{
let weak = window.as_weak();
let session = session.clone();
@@ -2459,22 +2532,42 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
{
// The rect arrives raw from the drag; the session normalises it, and
// the properties are written back from what it actually stored. That
// round trip is what makes an over-drag slide along the edge rather
// than letting the overlay and the pipeline disagree.
// The rect arrives raw from the drag; the session normalises it, holds
// it to whatever ratio is locked, and the properties are written back
// from what it actually stored. That round trip is what makes an
// over-drag slide along the edge rather than letting the overlay and
// the pipeline disagree — and it is what lets the lock reshape a drag
// without the overlay having to know a ratio exists.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_crop_changed(move |x, y, width, height| {
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_changed(move |x, y, width, height, hx, hy| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_crop(dr_pipeline::CropRect {
let rect = dr_pipeline::CropRect {
x,
y,
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
// nothing to reshape, and reshaping about a centre would drag
// an over-moved rect smaller instead of sliding it along the
// edge.
if hx < 0.0 {
s.set_crop(rect);
} else {
s.set_crop_locked(
rect,
crop_aspect.get(),
crop_portrait.get(),
(1.0 - hx, 1.0 - hy),
);
}
let c = s.crop();
w.set_crop_x(c.x);
w.set_crop_y(c.y);
@@ -2485,6 +2578,53 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
redraw(&w);
});
}
{
// TRACES: FR-DEV-3
// Choosing a ratio reshapes the crop there and then rather than
// waiting for the next drag. A lock that only took effect on the
// following gesture would leave the chip lit over a rect that is not
// that shape, which is the panel lying about the image.
//
// Held about the rect's centre, so the composition stays where it is
// and the shape changes around it.
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_aspect_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
let Some(&aspect) = develop::CropAspect::CHOICES.get(index.max(0) as usize) else {
return;
};
crop_aspect.set(aspect);
// A ratio with no second orientation cannot stay stood on its
// short edge, or the switch would be off and the crop upright.
if !aspect.has_orientation() {
crop_portrait.set(false);
}
sync_crop_aspect(&w, &crop_aspect, &crop_portrait);
apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let crop_aspect = crop_aspect.clone();
let crop_portrait = crop_portrait.clone();
window.on_crop_portrait_toggled(move || {
let Some(w) = weak.upgrade() else { return };
if !crop_aspect.get().has_orientation() {
return;
}
crop_portrait.set(!crop_portrait.get());
sync_crop_aspect(&w, &crop_aspect, &crop_portrait);
apply_crop_aspect(&w, &session, &crop_aspect, &crop_portrait);
redraw(&w);
});
}
// ---- rotation, flips and straightening -------------------------------
//