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:
@@ -173,6 +173,79 @@ impl CropRect {
|
|||||||
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
height: finite(self.height, 1.0).min(1.0 - y).max(Self::MIN_EXTENT),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Reshape onto an aspect ratio, holding one point of the rect still.
|
||||||
|
///
|
||||||
|
/// `ratio` is width over height **in output pixels** — 1.5 for 3:2 — which
|
||||||
|
/// is what a photographer means by an aspect ratio and is emphatically not
|
||||||
|
/// what this rect stores. The rect is in fractions of a frame that is
|
||||||
|
/// itself not square, so the ratio it needs is `ratio * height / width` of
|
||||||
|
/// that frame. Skipping the conversion yields a "1:1" crop that is square
|
||||||
|
/// only on a square photograph, which is the one case nobody tests on.
|
||||||
|
///
|
||||||
|
/// `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.
|
||||||
|
///
|
||||||
|
/// **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
|
||||||
|
/// inside instead reads as a dead control — dragging a corner outward
|
||||||
|
/// 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.
|
||||||
|
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);
|
||||||
|
if ratio <= 0.0 || frame_w == 0 || frame_h == 0 {
|
||||||
|
return rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width over height as a fraction of *this* frame, not in pixels.
|
||||||
|
let r = ratio * frame_h as f32 / frame_w as f32;
|
||||||
|
|
||||||
|
let ax = finite(anchor.0, 0.5).clamp(0.0, 1.0);
|
||||||
|
let ay = finite(anchor.1, 0.5).clamp(0.0, 1.0);
|
||||||
|
// Where the fixed point sits in the frame. Everything below is built
|
||||||
|
// outwards from it, which is what makes the anchor mean anything.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Scaled to fit, never clamped to fit: clamping one axis against the
|
||||||
|
// frame would break the very ratio this exists to hold.
|
||||||
|
let shrink = (room(px, ax) / w).min(room(py, ay) / h).min(1.0);
|
||||||
|
if shrink.is_finite() && shrink > 0.0 {
|
||||||
|
w *= shrink;
|
||||||
|
h *= shrink;
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
x: px - ax * w,
|
||||||
|
y: py - ay * h,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}
|
||||||
|
.normalised()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How far a rect anchored at `p` with the anchor `a` fractions along it may
|
||||||
|
/// extend before it leaves the `0..1` frame.
|
||||||
|
///
|
||||||
|
/// One axis at a time, and infinite where the anchor is at the far end and the
|
||||||
|
/// rect therefore only grows away from that edge.
|
||||||
|
fn room(p: f32, a: f32) -> f32 {
|
||||||
|
let before = if a > 0.0 { p / a } else { f32::INFINITY };
|
||||||
|
let after = if a < 1.0 {
|
||||||
|
(1.0 - p) / (1.0 - a)
|
||||||
|
} else {
|
||||||
|
f32::INFINITY
|
||||||
|
};
|
||||||
|
before.min(after)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace a non-finite value with a fallback.
|
/// Replace a non-finite value with a fallback.
|
||||||
@@ -1590,6 +1663,149 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- the aspect lock and the safe-area fit ---------------------------
|
||||||
|
|
||||||
|
/// A crop's shape in output pixels, which is the thing an aspect ratio is
|
||||||
|
/// about — the rect's own numbers are fractions of a frame that is not
|
||||||
|
/// square, so they are not comparable to `3.0 / 2.0` on their own.
|
||||||
|
fn pixel_ratio(c: CropRect, w: u32, h: u32) -> f32 {
|
||||||
|
(c.width * w as f32) / (c.height * h as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_ratio_is_a_ratio_of_pixels_not_of_fractions() {
|
||||||
|
// The whole of why `with_aspect` takes the frame size. On a 3:2
|
||||||
|
// photograph a square crop is *not* a square in fractions, and a
|
||||||
|
// version that skipped the conversion would pass every test written
|
||||||
|
// on a square frame.
|
||||||
|
let c = CropRect::default().with_aspect(6000, 4000, 1.0, (0.5, 0.5));
|
||||||
|
assert!(
|
||||||
|
(pixel_ratio(c, 6000, 4000) - 1.0).abs() < 1e-4,
|
||||||
|
"expected a square in pixels, got {c:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(c.width - c.height).abs() > 0.1,
|
||||||
|
"a square on a 3:2 frame must not be square in fractions: {c:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_offered_ratio_comes_back_at_the_ratio_asked_for() {
|
||||||
|
for (rw, rh) in [(1, 1), (3, 2), (4, 3), (5, 4), (16, 9), (2, 3), (9, 16)] {
|
||||||
|
let want = rw as f32 / rh as f32;
|
||||||
|
for (fw, fh) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||||||
|
let c = CropRect {
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.3,
|
||||||
|
width: 0.4,
|
||||||
|
height: 0.25,
|
||||||
|
}
|
||||||
|
.with_aspect(fw, fh, want, (0.5, 0.5));
|
||||||
|
let got = pixel_ratio(c, fw, fh);
|
||||||
|
assert!(
|
||||||
|
(got / want - 1.0).abs() < 1e-3,
|
||||||
|
"{rw}:{rh} on {fw}x{fh} came back as {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_locked_rect_stays_inside_the_frame_whatever_is_asked_for() {
|
||||||
|
// The reason the fit is a scale rather than a clamp: clamping one
|
||||||
|
// axis at the edge would hold the rect inside at the cost of the
|
||||||
|
// ratio, which is the one property the caller asked for.
|
||||||
|
for (rw, rh) in [(1, 1), (3, 2), (16, 9), (9, 16)] {
|
||||||
|
for anchor in [(0.0, 0.0), (1.0, 1.0), (1.0, 0.0), (0.5, 0.5)] {
|
||||||
|
let c = CropRect {
|
||||||
|
x: 0.05,
|
||||||
|
y: 0.9,
|
||||||
|
width: 0.9,
|
||||||
|
height: 0.09,
|
||||||
|
}
|
||||||
|
.with_aspect(6000, 4000, rw as f32 / rh as f32, anchor);
|
||||||
|
assert!(
|
||||||
|
c.x >= -1e-5
|
||||||
|
&& c.y >= -1e-5
|
||||||
|
&& c.x + c.width <= 1.0 + 1e-5
|
||||||
|
&& c.y + c.height <= 1.0 + 1e-5,
|
||||||
|
"{rw}:{rh} at {anchor:?} left the frame: {c:?}"
|
||||||
|
);
|
||||||
|
let got = pixel_ratio(c, 6000, 4000);
|
||||||
|
let want = rw as f32 / rh as f32;
|
||||||
|
assert!(
|
||||||
|
(got / want - 1.0).abs() < 1e-3,
|
||||||
|
"{rw}:{rh} at {anchor:?} lost its ratio: {got}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_anchor_corner_is_the_one_that_does_not_move() {
|
||||||
|
// What makes a corner drag feel like a corner drag: the opposite
|
||||||
|
// corner stays nailed down while the held one shapes the rect.
|
||||||
|
let start = CropRect {
|
||||||
|
x: 0.2,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.3,
|
||||||
|
};
|
||||||
|
// Bottom-right held still, top-left free.
|
||||||
|
let c = start.with_aspect(6000, 4000, 1.0, (1.0, 1.0));
|
||||||
|
assert!(
|
||||||
|
(c.x + c.width - (start.x + start.width)).abs() < 1e-5,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(c.y + c.height - (start.y + start.height)).abs() < 1e-5,
|
||||||
|
"{c:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Top-left held still, bottom-right free.
|
||||||
|
let c = start.with_aspect(6000, 4000, 1.0, (0.0, 0.0));
|
||||||
|
assert!((c.x - start.x).abs() < 1e-5, "{c:?}");
|
||||||
|
assert!((c.y - start.y).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
|
||||||
|
// axis clamps the first straight back, and the handle refuses to move.
|
||||||
|
let start = CropRect {
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.6,
|
||||||
|
height: 0.3,
|
||||||
|
};
|
||||||
|
let c = start.with_aspect(4000, 4000, 1.0, (0.0, 0.0));
|
||||||
|
assert!(
|
||||||
|
c.width >= start.width - 1e-5,
|
||||||
|
"{c:?} narrower than {start:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
c.height >= start.height - 1e-5,
|
||||||
|
"{c:?} shorter than {start:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_free_or_nonsense_ratio_leaves_the_rect_alone() {
|
||||||
|
// Nothing here should be a failure the caller has to handle: "no
|
||||||
|
// ratio" is the ordinary state of the crop tool.
|
||||||
|
let start = CropRect {
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.2,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.4,
|
||||||
|
};
|
||||||
|
for bad in [0.0, -1.5, f32::NAN, f32::INFINITY] {
|
||||||
|
assert_eq!(start.with_aspect(6000, 4000, bad, (0.5, 0.5)), start);
|
||||||
|
}
|
||||||
|
// A frame with no extent cannot define a ratio either.
|
||||||
|
assert_eq!(start.with_aspect(0, 0, 1.0, (0.5, 0.5)), start);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parameters_round_trip() {
|
fn parameters_round_trip() {
|
||||||
let mut f = Framing::new();
|
let mut f = Framing::new();
|
||||||
|
|||||||
+43
-43
File diff suppressed because one or more lines are too long
@@ -583,6 +583,87 @@ fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
|
|||||||
top * (1.0 - fy) + bot * fy
|
top * (1.0 - fy) + bot * fy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// A shape the crop rectangle is held to 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, and
|
||||||
|
/// a free crop makes them do the arithmetic by eye on every drag. This is the
|
||||||
|
/// lock that removes it.
|
||||||
|
///
|
||||||
|
/// **The ratio is of output pixels, not of the rect's own numbers.** The rect
|
||||||
|
/// is stored in fractions of a frame that is not square, so `CropRect` needs
|
||||||
|
/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is
|
||||||
|
/// where that conversion is done and explained.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum CropAspect {
|
||||||
|
/// Any shape. The handles move independently, as they always have.
|
||||||
|
#[default]
|
||||||
|
Free,
|
||||||
|
/// Whatever the frame already is, so a crop trims without reshaping.
|
||||||
|
///
|
||||||
|
/// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the
|
||||||
|
/// frame, so it stays right on the next photograph from another body and
|
||||||
|
/// after a quarter turn.
|
||||||
|
Original,
|
||||||
|
/// A named ratio of `w:h`, before the portrait switch is applied.
|
||||||
|
Fixed(u32, u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CropAspect {
|
||||||
|
/// The ratios the panel offers, in the order it draws them.
|
||||||
|
///
|
||||||
|
/// Short on purpose. These sit as chips in a column narrow enough for a
|
||||||
|
/// tablet, and every ratio a photographer reaches for repeatedly is here:
|
||||||
|
/// the frame's own shape, the square, the two classic camera ratios, the
|
||||||
|
/// large-format one that most print papers follow, and video's.
|
||||||
|
pub const CHOICES: [Self; 6] = [
|
||||||
|
Self::Free,
|
||||||
|
Self::Original,
|
||||||
|
Self::Fixed(1, 1),
|
||||||
|
Self::Fixed(3, 2),
|
||||||
|
Self::Fixed(4, 3),
|
||||||
|
Self::Fixed(16, 9),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The chip's text.
|
||||||
|
pub fn label(self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Free => "Free".to_string(),
|
||||||
|
Self::Original => "Original".to_string(),
|
||||||
|
Self::Fixed(w, h) => format!("{w}:{h}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this choice has a portrait form at all.
|
||||||
|
///
|
||||||
|
/// A square does not, and neither does `Free`. The switch is disabled
|
||||||
|
/// rather than hidden for those, so the row does not change shape as the
|
||||||
|
/// chips are tried.
|
||||||
|
pub fn has_orientation(self) -> bool {
|
||||||
|
!matches!(self, Self::Free | Self::Fixed(1, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Width over height in output pixels, or `None` where nothing is locked.
|
||||||
|
///
|
||||||
|
/// `frame` is the framed size the crop is measured against — the turned
|
||||||
|
/// frame, not the sensor — which is what makes `Original` follow a quarter
|
||||||
|
/// turn instead of becoming a portrait crop on a landscape photograph.
|
||||||
|
pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option<f32> {
|
||||||
|
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||||||
|
let landscape = match self {
|
||||||
|
Self::Free => return None,
|
||||||
|
Self::Original => fw / fh,
|
||||||
|
Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32,
|
||||||
|
};
|
||||||
|
Some(if portrait && self.has_orientation() {
|
||||||
|
1.0 / landscape
|
||||||
|
} else {
|
||||||
|
landscape
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DevelopSession {
|
pub struct DevelopSession {
|
||||||
/// This session's name, for work that outlives the frame it started on.
|
/// This session's name, for work that outlives the frame it started on.
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
@@ -3138,10 +3219,48 @@ impl DevelopSession {
|
|||||||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// Set the crop rectangle, held to `aspect` about `anchor`.
|
||||||
|
///
|
||||||
|
/// The frame size the ratio needs is this session's own, so the caller
|
||||||
|
/// passes a shape rather than a rectangle and never has to know what a
|
||||||
|
/// quarter turn did to the frame's dimensions.
|
||||||
|
///
|
||||||
|
/// `anchor` is the point of the rect that must not move, in the rect's own
|
||||||
|
/// `0..1` coordinates — the corner *opposite* the handle being dragged, so
|
||||||
|
/// that shaping the rect onto the ratio pushes the held corner and leaves
|
||||||
|
/// the far one where the user put it.
|
||||||
|
pub fn set_crop_locked(
|
||||||
|
&mut self,
|
||||||
|
rect: CropRect,
|
||||||
|
aspect: CropAspect,
|
||||||
|
portrait: bool,
|
||||||
|
anchor: (f32, f32),
|
||||||
|
) {
|
||||||
|
let frame = self.framed_size();
|
||||||
|
let rect = match aspect.ratio(frame, portrait) {
|
||||||
|
Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor),
|
||||||
|
None => rect,
|
||||||
|
};
|
||||||
|
self.set_crop(rect);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn crop(&self) -> CropRect {
|
pub fn crop(&self) -> CropRect {
|
||||||
self.graph.crop()
|
self.graph.crop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The whole frame the crop is measured against, in output pixels.
|
||||||
|
///
|
||||||
|
/// The *framed* size, not the sensor's: quarter turns swap the axes, and a
|
||||||
|
/// ratio resolved against the sensor would come out on its side the moment
|
||||||
|
/// a portrait photograph was turned upright. The crop is excluded because
|
||||||
|
/// this is the shape being selected *from*.
|
||||||
|
pub fn framed_size(&self) -> (u32, u32) {
|
||||||
|
let (sw, sh) = self.demosaiced.size();
|
||||||
|
self.graph.framing().output_size_uncropped(sw, sh)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||||||
///
|
///
|
||||||
/// The crop travels with the frame rather than staying where it was on
|
/// The crop travels with the frame rather than staying where it was on
|
||||||
|
|||||||
+147
-7
@@ -359,6 +359,58 @@ fn sync_framing(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>
|
|||||||
window.set_crop_h(crop.height);
|
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
|
/// TRACES: FR-EXP-6 | FR-EXP-9 | FR-EXP-7
|
||||||
/// Render the open image at full resolution, ready to be handed to the worker.
|
/// 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
|
// Zoom and pan are viewing state and touch no parameter, so unlike the
|
||||||
// handlers above they do not `sync_rows`.
|
// 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 weak = window.as_weak();
|
||||||
let session = session.clone();
|
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 rect arrives raw from the drag; the session normalises it, holds
|
||||||
// the properties are written back from what it actually stored. That
|
// it to whatever ratio is locked, and the properties are written back
|
||||||
// round trip is what makes an over-drag slide along the edge rather
|
// from what it actually stored. That round trip is what makes an
|
||||||
// than letting the overlay and the pipeline disagree.
|
// 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 weak = window.as_weak();
|
||||||
let session = session.clone();
|
let session = session.clone();
|
||||||
let redraw = redraw.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 };
|
let Some(w) = weak.upgrade() else { return };
|
||||||
if let Some(s) = session.borrow_mut().as_mut() {
|
if let Some(s) = session.borrow_mut().as_mut() {
|
||||||
s.set_crop(dr_pipeline::CropRect {
|
let rect = dr_pipeline::CropRect {
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
width,
|
width,
|
||||||
height,
|
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();
|
let c = s.crop();
|
||||||
w.set_crop_x(c.x);
|
w.set_crop_x(c.x);
|
||||||
w.set_crop_y(c.y);
|
w.set_crop_y(c.y);
|
||||||
@@ -2485,6 +2578,53 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
|||||||
redraw(&w);
|
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 -------------------------------
|
// ---- rotation, flips and straightening -------------------------------
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
|
import { PanelHeading, Label, Value, Caption, Button, IconButton, Swatch } from "widgets.slint";
|
||||||
import { SliderTrack, ControlRow, CurveEditor, Segmented } from "controls.slint";
|
import { SliderTrack, ControlRow, CurveEditor, Segmented, ChoiceChip } from "controls.slint";
|
||||||
|
|
||||||
// One parameter, flattened for Slint's model system.
|
// One parameter, flattened for Slint's model system.
|
||||||
//
|
//
|
||||||
@@ -496,11 +496,25 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
/// Any of crop, angle, rotation or flips differs from neutral.
|
/// Any of crop, angle, rotation or flips differs from neutral.
|
||||||
in property <bool> modified: false;
|
in property <bool> modified: false;
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The crop overlay is up, so the ratio lock is a control for a gesture
|
||||||
|
/// that is currently on screen.
|
||||||
|
in property <bool> cropping: false;
|
||||||
|
/// The ratios on offer, named by Rust — see `CropAspect::CHOICES`.
|
||||||
|
in property <[string]> aspects;
|
||||||
|
in property <int> aspect: 0;
|
||||||
|
in property <bool> portrait: false;
|
||||||
|
/// Whether the chosen ratio has a second orientation. `Free` and the
|
||||||
|
/// square do not.
|
||||||
|
in property <bool> aspect-turnable: false;
|
||||||
|
|
||||||
callback rotate(int);
|
callback rotate(int);
|
||||||
callback flip-h-toggled();
|
callback flip-h-toggled();
|
||||||
callback flip-v-toggled();
|
callback flip-v-toggled();
|
||||||
callback angle-changed(float);
|
callback angle-changed(float);
|
||||||
callback angle-reset();
|
callback angle-reset();
|
||||||
|
callback aspect-picked(int);
|
||||||
|
callback portrait-toggled();
|
||||||
callback reset();
|
callback reset();
|
||||||
|
|
||||||
/// TRACES: FR-UI-2
|
/// TRACES: FR-UI-2
|
||||||
@@ -592,6 +606,54 @@ export component GeometryPanel inherits Rectangle {
|
|||||||
changed(v) => { root.angle-changed(v); }
|
changed(v) => { root.angle-changed(v); }
|
||||||
reset => { root.angle-reset(); }
|
reset => { root.angle-reset(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-DEV-3
|
||||||
|
// The ratio the crop is held to.
|
||||||
|
//
|
||||||
|
// **A grid rather than a row.** Six chips laid side by side need
|
||||||
|
// over 400px, and this column's width is the largest any panel
|
||||||
|
// declares — so a single row would widen every other panel in the
|
||||||
|
// application to fit a control that is on screen only while
|
||||||
|
// cropping. Three columns is what the column already has room for.
|
||||||
|
//
|
||||||
|
// The chips are drawn from the model rather than written out, so
|
||||||
|
// the offered set lives in one place: `CropAspect::CHOICES`. Their
|
||||||
|
// positions are computed from the index for the same reason — a
|
||||||
|
// seventh ratio wraps onto a third row by itself.
|
||||||
|
if root.cropping: VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
padding-top: Theme.gap-sm;
|
||||||
|
|
||||||
|
Label { text: "Ratio"; body: true; }
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
for name[i] in root.aspects: ChoiceChip {
|
||||||
|
row: floor(i / 3);
|
||||||
|
col: mod(i, 3);
|
||||||
|
label: name;
|
||||||
|
selected: i == root.aspect;
|
||||||
|
enabled: root.enabled;
|
||||||
|
clicked => { root.aspect-picked(i); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One chip, lit when the ratio is standing on its short edge,
|
||||||
|
// rather than a Landscape/Portrait pair: the second chip in
|
||||||
|
// such a pair says nothing the first does not, and this row is
|
||||||
|
// already the widest thing in the panel.
|
||||||
|
//
|
||||||
|
// Disabled rather than hidden where the ratio has no second
|
||||||
|
// orientation, so the panel does not change height as the
|
||||||
|
// chips above it are tried.
|
||||||
|
ChoiceChip {
|
||||||
|
label: "Portrait";
|
||||||
|
selected: root.portrait;
|
||||||
|
enabled: root.enabled && root.aspect-turnable;
|
||||||
|
clicked => { root.portrait-toggled(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -115,10 +115,31 @@ export component AppWindow inherits Window {
|
|||||||
in-out property <float> crop-w: 1.0;
|
in-out property <float> crop-w: 1.0;
|
||||||
in-out property <float> crop-h: 1.0;
|
in-out property <float> crop-h: 1.0;
|
||||||
|
|
||||||
|
/// TRACES: FR-DEV-3
|
||||||
|
/// The ratios the crop may be locked to, and which one is chosen.
|
||||||
|
///
|
||||||
|
/// Named by Rust rather than listed here: the set is `CropAspect::CHOICES`
|
||||||
|
/// and the labels come off it, so a ratio added there appears without this
|
||||||
|
/// file changing.
|
||||||
|
in property <[string]> crop-aspects;
|
||||||
|
in property <int> crop-aspect: 0;
|
||||||
|
/// Whether the chosen ratio is standing on its short edge.
|
||||||
|
in property <bool> crop-portrait: false;
|
||||||
|
/// Whether the chosen ratio has a portrait form at all — `Free` and the
|
||||||
|
/// square do not, and the switch says so rather than vanishing.
|
||||||
|
in property <bool> crop-aspect-turnable: false;
|
||||||
|
|
||||||
/// Enter a mode, or `photo` to leave whichever one is current.
|
/// Enter a mode, or `photo` to leave whichever one is current.
|
||||||
callback mode-picked(ViewMode);
|
callback mode-picked(ViewMode);
|
||||||
/// A dragged crop rect, in fractions of the frame.
|
/// A dragged crop rect in fractions of the frame, then the corner the drag
|
||||||
callback crop-changed(float, float, float, float);
|
/// is holding as a `0`/`1` pair — or `-1, -1` for a move, which keeps the
|
||||||
|
/// shape it has. See `CropOverlay` in crop.slint for why the corner has to
|
||||||
|
/// travel with the rect.
|
||||||
|
callback crop-changed(float, float, float, float, float, float);
|
||||||
|
/// Lock the crop to one of `crop-aspects`, by index.
|
||||||
|
callback crop-aspect-picked(int);
|
||||||
|
/// Stand the chosen ratio on its other edge.
|
||||||
|
callback crop-portrait-toggled();
|
||||||
|
|
||||||
// --- rotation, flips and straightening (FR-DEV-3) ---
|
// --- rotation, flips and straightening (FR-DEV-3) ---
|
||||||
//
|
//
|
||||||
@@ -1758,7 +1779,9 @@ in property <bool> panel-visible: true;
|
|||||||
crop-w: root.crop-w;
|
crop-w: root.crop-w;
|
||||||
crop-h: root.crop-h;
|
crop-h: root.crop-h;
|
||||||
|
|
||||||
crop-changed(x, y, w, h) => { root.crop-changed(x, y, w, h); }
|
crop-changed(x, y, w, h, hx, hy) => {
|
||||||
|
root.crop-changed(x, y, w, h, hx, hy);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- gradient handles ---------------------------------------
|
// --- gradient handles ---------------------------------------
|
||||||
@@ -2118,6 +2141,19 @@ in property <bool> panel-visible: true;
|
|||||||
flip-v: root.flip-v;
|
flip-v: root.flip-v;
|
||||||
modified: root.framing-modified;
|
modified: root.framing-modified;
|
||||||
|
|
||||||
|
// The ratio chips belong to the crop tool, so
|
||||||
|
// they appear with it. Shown at all times they
|
||||||
|
// would be a control for a gesture that is not
|
||||||
|
// on screen; hidden behind a lid they would be
|
||||||
|
// a feature nobody finds.
|
||||||
|
cropping: root.cropping;
|
||||||
|
aspects: root.crop-aspects;
|
||||||
|
aspect: root.crop-aspect;
|
||||||
|
portrait: root.crop-portrait;
|
||||||
|
aspect-turnable: root.crop-aspect-turnable;
|
||||||
|
aspect-picked(i) => { root.crop-aspect-picked(i); }
|
||||||
|
portrait-toggled => { root.crop-portrait-toggled(); }
|
||||||
|
|
||||||
rotate(turns) => { root.rotate-quarters(turns); }
|
rotate(turns) => { root.rotate-quarters(turns); }
|
||||||
flip-h-toggled => { root.flip-h-toggled(); }
|
flip-h-toggled => { root.flip-h-toggled(); }
|
||||||
flip-v-toggled => { root.flip-v-toggled(); }
|
flip-v-toggled => { root.flip-v-toggled(); }
|
||||||
|
|||||||
+17
-2
@@ -37,8 +37,16 @@ export component CropOverlay inherits Rectangle {
|
|||||||
in property <float> crop-w: 1.0;
|
in property <float> crop-w: 1.0;
|
||||||
in property <float> crop-h: 1.0;
|
in property <float> crop-h: 1.0;
|
||||||
|
|
||||||
/// A dragged crop rect, in the same fractions.
|
/// A dragged crop rect — `x`, `y`, `width`, `height`, then the corner the
|
||||||
callback crop-changed(float, float, float, float);
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
/// 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.
|
||||||
|
callback crop-changed(float, float, float, float, float, float);
|
||||||
|
|
||||||
|
|
||||||
property <length> rx: root.crop-x * self.width;
|
property <length> rx: root.crop-x * self.width;
|
||||||
@@ -153,6 +161,11 @@ export component CropOverlay inherits Rectangle {
|
|||||||
self.start-y + self.fraction-y(self.mouse-y) - self.from-y,
|
self.start-y + self.fraction-y(self.mouse-y) - self.from-y,
|
||||||
root.crop-w,
|
root.crop-w,
|
||||||
root.crop-h,
|
root.crop-h,
|
||||||
|
// No corner: a move keeps the shape it already has,
|
||||||
|
// and an over-drag should slide along the edge rather
|
||||||
|
// than be reshaped back inside.
|
||||||
|
-1,
|
||||||
|
-1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,6 +245,8 @@ export component CropOverlay inherits Rectangle {
|
|||||||
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
corner.hy == 0 ? self.oy + self.dy : self.oy,
|
||||||
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx,
|
||||||
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy,
|
||||||
|
corner.hx,
|
||||||
|
corner.hy,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user