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
+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(); }
}
}
}
}
}