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
+216
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+119
View File
@@ -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
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 -------------------------------
//
+63 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
}
}