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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -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]
|
||||
fn parameters_round_trip() {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// This session's name, for work that outlives the frame it started on.
|
||||
id: SessionId,
|
||||
@@ -3138,10 +3219,48 @@ impl DevelopSession {
|
||||
.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 {
|
||||
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.
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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 -------------------------------
|
||||
//
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { Theme } from "theme.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.
|
||||
//
|
||||
@@ -496,11 +496,25 @@ export component GeometryPanel inherits Rectangle {
|
||||
/// Any of crop, angle, rotation or flips differs from neutral.
|
||||
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 flip-h-toggled();
|
||||
callback flip-v-toggled();
|
||||
callback angle-changed(float);
|
||||
callback angle-reset();
|
||||
callback aspect-picked(int);
|
||||
callback portrait-toggled();
|
||||
callback reset();
|
||||
|
||||
/// TRACES: FR-UI-2
|
||||
@@ -592,6 +606,54 @@ export component GeometryPanel inherits Rectangle {
|
||||
changed(v) => { root.angle-changed(v); }
|
||||
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-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.
|
||||
callback mode-picked(ViewMode);
|
||||
/// A dragged crop rect, in fractions of the frame.
|
||||
callback crop-changed(float, float, float, float);
|
||||
/// A dragged crop rect in fractions of the frame, then the corner the drag
|
||||
/// 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) ---
|
||||
//
|
||||
@@ -1758,7 +1779,9 @@ in property <bool> panel-visible: true;
|
||||
crop-w: root.crop-w;
|
||||
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 ---------------------------------------
|
||||
@@ -2118,6 +2141,19 @@ in property <bool> panel-visible: true;
|
||||
flip-v: root.flip-v;
|
||||
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); }
|
||||
flip-h-toggled => { root.flip-h-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-h: 1.0;
|
||||
|
||||
/// A dragged crop rect, in the same fractions.
|
||||
callback crop-changed(float, float, float, float);
|
||||
/// 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.
|
||||
///
|
||||
/// 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;
|
||||
@@ -153,6 +161,11 @@ export component CropOverlay inherits Rectangle {
|
||||
self.start-y + self.fraction-y(self.mouse-y) - self.from-y,
|
||||
root.crop-w,
|
||||
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.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user