Merge #10941: Crop: ⇧-corner drag follows the frame's diagonal (#10871)

This commit is contained in:
David Mann committed 2026-11-03 00:30:49 +01:00
commit c0cfcb4e38
4 files changed
+128 -6

No files matched your search

+28 -4
View File
@@ -8,8 +8,8 @@
//! being drawn;
//! - inside the frame, a drag moves it;
//! - on an edge or corner, a drag resizes it along the frame's own (possibly turned) axes; ⇧ keeps
//! the frame's aspect ratio, ⌥ resizes about the centre, and an options-bar ratio preset always
//! holds;
//! the frame's aspect ratio (on a corner the pointer is followed along the frame's diagonal),
//! ⌥ resizes about the centre, and an options-bar ratio preset always holds;
//! - outside the frame (off its handles) the pointer is the rotate cursor and a drag turns the
//! frame about its centre, ⇧ in 15° steps, with the angle beside the pointer (#1792). As in
//! Photoshop, a new frame is drawn after the pending one is committed or cancelled;
@@ -204,6 +204,23 @@ pub fn resized_turned(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2], ratio:
[nc[0] - hw, nc[1] - hh, nc[0] + hw, nc[1] + hh]
}
/// Drag `d` on corner (`hx`, `hy`) of frame `r` turned `deg` degrees, projected onto the frame's
/// diagonal through that corner: a ⇧ corner drag with a free ratio slides the corner along it, as
/// in Photoshop (#10871). A corner drag on a ratio otherwise lets the larger relative extent win,
/// so (−100, −50) on 800 × 500 gave 720 × 450 instead of Photoshop's 706 × 441.
fn along_diagonal(r: [f64; 4], deg: f64, hx: i8, hy: i8, d: [f64; 2]) -> [f64; 2] {
let v = turn([f64::from(hx) * (r[2] - r[0]), f64::from(hy) * (r[3] - r[1])], [0.0, 0.0], deg);
let vv = v[0] * v[0] + v[1] * v[1];
let t = (d[0] * v[0] + d[1] * v[1]) / vv;
if vv > 0.0 && t.is_finite() { [t * v[0], t * v[1]] } else { d }
}
/// ⇧ on a corner with no ratio preset and no ⌥: the corner follows the frame's diagonal
/// ([`along_diagonal`]). A preset, ⌥ ⇧ and the edge handles keep the larger-extent rule.
fn follows_diagonal(app: &PhotocraftApp, hx: i8, hy: i8, mods: Modifiers) -> bool {
mods.shift && !mods.alt && hx != 0 && hy != 0 && preset_ratio(app).is_none()
}
/// `[x0, y0, x1, y1]` with x0 ≤ x1, y0 ≤ y1.
fn normalized(a: [f64; 2], b: [f64; 2]) -> [f64; 4] {
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
@@ -935,9 +952,13 @@ fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bo
}
Some(CropDrag::Resize { hx, hy, start, rect, view: Some(view) }) => {
// Auto Center Preview keeps the box centred while the handle follows the pointer.
let at = crate::crop_mode::to_press(app, Some(view), p);
let mut at = crate::crop_mode::to_press(app, Some(view), p);
let ratio = resize_ratio(app, rect, mods);
let deg = angle(app);
if follows_diagonal(app, hx, hy, mods) {
let d = along_diagonal(rect, deg, hx, hy, [at[0] - start[0], at[1] - start[1]]);
at = [start[0] + d[0], start[1] + d[1]];
}
crate::crop_mode::live_resize(app, crate::crop_mode::LiveResize { rect, deg, hx, hy, start, q: at, ratio, alt: mods.alt }, view);
}
_ => return false,
@@ -950,6 +971,7 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio
let ratio = preset_ratio(app);
let space = app.crop.space;
let deg = angle(app);
let diagonal = matches!(app.crop.drag, Some(CropDrag::Resize { hx, hy, .. }) if follows_diagonal(app, hx, hy, mods));
match app.crop.drag.as_mut()? {
CropDrag::Draw { anchor, cur, last, .. } => {
if space {
@@ -973,7 +995,9 @@ fn dragged_frame(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> Optio
(w > 0.0 && h > 0.0).then(|| w / h)
};
let ratio = ratio.or_else(|| if mods.shift { keep() } else { None });
Some(resized_turned(*rect, deg, *hx, *hy, [p[0] - start[0], p[1] - start[1]], ratio, mods.alt))
let d = [p[0] - start[0], p[1] - start[1]];
let d = if diagonal { along_diagonal(*rect, deg, *hx, *hy, d) } else { d };
Some(resized_turned(*rect, deg, *hx, *hy, d, ratio, mods.alt))
}
CropDrag::Rotate { .. } | CropDrag::TurnImage { .. } | CropDrag::MoveImage { .. } => None,
}
+3 -2
View File
@@ -387,13 +387,14 @@ fn handles_resize_with_shift_and_alt() {
// Bottom-right corner, free.
drag(&mut app, &[[120.0, 60.0], [140.0, 70.0]], NONE);
assert_eq!(app.ui.crop_rect, Some([20.0, 20.0, 140.0, 70.0]));
// ⇧ keeps 120:50 from the top-left corner: the larger relative extent wins.
// ⇧ keeps 120:50 from the top-left corner: the corner follows the frame's diagonal, so the
// drag (−10, −20) projected onto (−120, −50) grows the frame by 2200 / 16900 (#10871).
app.ui.crop_rect = Some([20.0, 20.0, 140.0, 70.0]);
drag(&mut app, &[[20.0, 20.0], [10.0, 0.0]], SHIFT);
let r = app.ui.crop_rect.unwrap();
assert_eq!((r[2], r[3]), (140.0, 70.0), "opposite corner fixed");
assert!(((r[2] - r[0]) / (r[3] - r[1]) - 120.0 / 50.0).abs() < 1e-9, "{r:?}");
assert_eq!(r[3] - r[1], 70.0);
assert!((r[3] - r[1] - 50.0 * (1.0 + 2200.0 / 16900.0)).abs() < 1e-9, "{r:?}");
// ⌥ resizes about the centre.
app.ui.crop_rect = Some([40.0, 20.0, 80.0, 60.0]);
drag(&mut app, &[[80.0, 40.0], [90.0, 40.0]], Modifiers::ALT);
@@ -0,0 +1,96 @@
//! Crop tool: ⇧-dragging a frame corner keeps its proportions by following the pointer along the
//! frame's diagonal, as Photoshop does (#10871).
//!
//! Measured in Photoshop: 800 × 500 document, frame = whole canvas, free ratio, Auto Center
//! Preview off, ⇧-drag of the bottom-right corner by (−100, −50) px commits 706 × 441 (the drag
//! projected onto (800, 500) shortens the frame by 11.8 %). PhotoCraft let the larger relative
//! extent win and committed 720 × 450.
//!
//! Driven through `ui.pointer`, as an agent or the canvas sends the drag; no GPU needed.
use photocraft_doc::{Color, ColorMode, Document, SampleType, Size};
use photocraft_ui_egui::PhotocraftApp;
use photocraft_ui_egui::control::{ControlRequest, Outcome, handle};
use serde_json::{Value, json};
/// An 800 × 500 document at 100 % in a canvas it fits, the Crop tool's default frame up.
fn crop_app(auto_center: bool) -> PhotocraftApp {
let doc = Document::with_background("crop", Size::new(800, 500), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut s = photocraft_engine::Session::new();
s.add_document(doc, None);
let mut app = PhotocraftApp::new(s, Default::default());
app.sync_views();
app.ui.extras.snap = false;
app.ui.extras.rulers = false;
app.ui.view.show.smart_guides = false;
app.last_canvas_rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0));
let v = &mut app.ui.views[0];
(v.zoom, v.center, v.fit_pending, v.rotation) = (1.0, [400.0, 250.0], false, 0.0);
app.ui.tool_options.crop_shield.auto_center_preview = auto_center;
pointer(&mut app, json!({"tool": "Crop", "events": []}));
photocraft_ui_egui::crop_ui::ensure_frame(&mut app);
assert_eq!(app.ui.crop_rect, Some([0.0, 0.0, 800.0, 500.0]), "the default frame is the whole canvas");
app
}
fn pointer(app: &mut PhotocraftApp, params: Value) {
let ctx = egui::Context::default();
let (req, _rx) = ControlRequest::new("ui.pointer", params);
let Outcome::Done(v) = handle(app, &ctx, &req) else { panic!("ui.pointer didn't finish") };
assert_eq!(v["ok"], true, "{v}");
}
/// Drags the bottom-right corner by `d` in steps, holding `mods`, and commits with ↵.
fn drag_corner_and_commit(app: &mut PhotocraftApp, d: [f64; 2], mods: Value) -> (u32, u32) {
let (x, y) = (800.0, 500.0);
let mut events = vec![json!({"kind": "down", "x": x, "y": y})];
events.extend((1..=4).map(|i| {
let t = f64::from(i) / 4.0;
json!({"kind": "move", "x": x + d[0] * t, "y": y + d[1] * t})
}));
events.push(json!({"kind": "up", "x": x + d[0], "y": y + d[1]}));
let mut params = mods;
params["events"] = Value::Array(events);
pointer(app, params);
photocraft_ui_egui::canvas::commit_crop(app);
let size = app.session.active().expect("a document").doc.size;
(size.width, size.height)
}
fn assert_near(got: (u32, u32), want: (u32, u32), what: &str) {
let off = |a: u32, b: u32| a.abs_diff(b);
assert!(off(got.0, want.0) <= 1 && off(got.1, want.1) <= 1, "{what}: committed {got:?}, want {want:?} ±1 px");
}
#[test]
fn shift_corner_drag_follows_the_frames_diagonal() {
let mut app = crop_app(false);
let got = drag_corner_and_commit(&mut app, [-100.0, -50.0], json!({"shift": true}));
assert_near(got, (706, 441), "⇧ drag by (−100, −50)");
}
#[test]
fn shift_corner_drag_outward_past_the_canvas_follows_the_diagonal() {
let mut app = crop_app(false);
// (100, 50) onto (800, 500) grows the frame by 11.8 %: 894.4 × 559.0, past the canvas.
let got = drag_corner_and_commit(&mut app, [100.0, 50.0], json!({"shift": true}));
assert_near(got, (894, 559), "⇧ drag by (100, 50)");
}
#[test]
fn shift_corner_drag_keeps_the_proportions_with_auto_center_preview() {
let mut app = crop_app(true);
let (w, h) = drag_corner_and_commit(&mut app, [-100.0, -50.0], json!({"shift": true}));
assert!(w < 800 && h < 500, "the frame shrank: {w} × {h}");
assert!((f64::from(w) / f64::from(h) - 1.6).abs() < 0.01, "8 : 5 kept: {w} × {h}");
}
#[test]
fn the_same_drag_without_shift_or_with_alt_is_unchanged() {
// No ⇧: the corner follows the pointer freely, 700 × 450 as in Photoshop.
let mut app = crop_app(false);
assert_near(drag_corner_and_commit(&mut app, [-100.0, -50.0], json!({})), (700, 450), "no ⇧");
// ⌥ ⇧ (not measured, today's rule): about the centre, 600 × 400 grows to 8 : 5 as 640 × 400.
let mut app = crop_app(false);
assert_near(drag_corner_and_commit(&mut app, [-100.0, -50.0], json!({"shift": true, "alt": true})), (640, 400), "⌥ ⇧");
}
+1
View File
@@ -14,6 +14,7 @@ mod color_managed_canvas;
mod crop_beyond_canvas;
mod crop_default_mode;
mod crop_popup_keys;
mod crop_shift_corner;
mod custom_print_paper;
mod display_shadows;
mod drag_preview_canvas;