diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..a1588ea7e6 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -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, } diff --git a/crates/ui-egui/src/tools/crop_ui/tests.rs b/crates/ui-egui/src/tools/crop_ui/tests.rs index 6fba4e57d4..65b35c689a 100644 --- a/crates/ui-egui/src/tools/crop_ui/tests.rs +++ b/crates/ui-egui/src/tools/crop_ui/tests.rs @@ -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); diff --git a/crates/ui-egui/tests/it/crop_shift_corner.rs b/crates/ui-egui/tests/it/crop_shift_corner.rs new file mode 100644 index 0000000000..6fba00ed02 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_shift_corner.rs @@ -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), "⌥ ⇧"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..90db2f9773 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -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;