From 3c82477fac87c835b3585572cac0d9bf1fd92945 Mon Sep 17 00:00:00 2001 From: ljkx <177671075+ljkx@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:57:16 +0100 Subject: [PATCH] Crop: turning the image keeps the frame inside it (#10869) In the default mode a drag outside the box turns the image, but the frame never changed, so its corners left the turned image and the crop filled them with the background colour. Each move of the turn now starts from the frame at the press: it keeps its size and place if it fits the turned image, else moves the shortest way in, else shrinks about its centre (keeping its proportions) to the largest size that fits. The view follows the frame's centre, so the box stays put on screen. Ctrl (Cmd) held during the turn leaves the frame as it was. Content-Aware Fill and a frame already past the canvas keep today's behaviour (not measured). --- crates/ui-egui/src/tools/crop_mode.rs | 71 +++- crates/ui-egui/src/tools/crop_ui.rs | 10 +- .../tests/it/crop_turn_keeps_frame_inside.rs | 308 ++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs diff --git a/crates/ui-egui/src/tools/crop_mode.rs b/crates/ui-egui/src/tools/crop_mode.rs index cc946b30b9..14eb64c8d3 100644 --- a/crates/ui-egui/src/tools/crop_mode.rs +++ b/crates/ui-egui/src/tools/crop_mode.rs @@ -3,7 +3,8 @@ //! - **Default mode** (Photoshop's default, Use Classic Mode off): the crop box stays upright on //! screen and the image turns behind it. A drag outside the box turns the image about the box's //! centre (⇧ in 15° steps; the readout beside the pointer is the image's turn, clockwise -//! positive), a drag inside moves the image under the box (the box stays put; with View › Snap +//! positive) and keeps the frame inside the turned image ([`keep_inside`]; not with ⌘ held), a +//! drag inside moves the image under the box (the box stays put; with View › Snap //! on, the upright frame's edges and centre snap as a Classic Mode frame's do, #8580), the arrow //! keys nudge the image, and the handles resize the box on screen as usual. With **Auto Center //! Preview** on (the default), a box being resized keeps its centre where it is on screen (also @@ -114,6 +115,74 @@ pub fn image_turn(frame_angle: f64) -> f64 { if a == 0.0 { 0.0 } else { a } } +/// How far (document px) a turned frame's corner may lie past the canvas and still count as on +/// it: rounding, not a visible gap. +const FIT_TOLERANCE: f64 = 1e-6; + +/// Frame `rect` turned `deg` degrees about its centre, kept inside a `cw`×`ch` canvas (#10869): +/// `rect` itself when it fits; else moved the shortest way in; else shrunk about its centre, +/// keeping its proportions, to the largest size that fits anywhere, then moved in. `None` when a +/// size or the angle isn't finite, or a size isn't positive. +pub fn kept_inside(rect: [f64; 4], deg: f64, cw: f64, ch: f64) -> Option<[f64; 4]> { + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + if ![w, h, cw, ch, deg].iter().all(|v| v.is_finite()) || w <= 0.0 || h <= 0.0 || cw <= 0.0 || ch <= 0.0 { + return None; + } + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + // Half the turned frame's bounds: its corners are on the canvas when these are. + let (ex, ey) = ((w * c + h * s) / 2.0, (w * s + h * c) / 2.0); + let m = crate::crop_ui::center(rect); + // A frame that fits stays bit for bit, also one whose corners a rounding error puts a hair + // past the edges (a frame fitted by an earlier turn touches them). + let fits = |v: f64, e: f64, len: f64| v - e >= -FIT_TOLERANCE && v + e <= len + FIT_TOLERANCE; + if fits(m[0], ex, cw) && fits(m[1], ey, ch) { + return Some(rect); + } + // The scale that makes the bounds fit the canvas; a rounding error short of 1 (180°) fits. + let k = (cw / (2.0 * ex)).min(ch / (2.0 * ey)); + let k = if k >= 1.0 - 1e-9 { 1.0 } else { k }; + // Each axis on its own: the nearest centre that keeps the bounds on the canvas. + let along = |v: f64, e: f64, len: f64| if 2.0 * e >= len { len / 2.0 } else { v.clamp(e, len - e) }; + let n = [along(m[0], ex * k, cw), along(m[1], ey * k, ch)]; + let r = if k == 1.0 { + // Kept size: moved by exactly the offset. + let d = [n[0] - m[0], n[1] - m[1]]; + [rect[0] + d[0], rect[1] + d[1], rect[2] + d[0], rect[3] + d[1]] + } else { + let (hw, hh) = (w * k / 2.0, h * k / 2.0); + [n[0] - hw, n[1] - hh, n[0] + hw, n[1] + hh] + }; + r.iter().all(|v| v.is_finite()).then_some(r) +} + +/// A default-mode turn keeps the frame inside the turned image, as in Photoshop (#10869): the +/// frame the drag started from, `rect` (turned `from` degrees then), keeps its size and place +/// when it fits the image at the frame's angle now, else [`kept_inside`] moves it in or shrinks +/// it. Each step starts from the press frame, so turning back gives the frame back. The view +/// moves with the frame's centre, so the box stays put on screen. With ⌘ (Ctrl) `held` the frame +/// stays as it was at the press and ↵ fills the uncovered corners. +/// +/// Not measured in Photoshop, so they keep the frame as before: Fill set to Content-Aware Fill, +/// and a frame that already reached past the canvas at the press. +pub fn keep_inside(app: &mut PhotocraftApp, rect: [f64; 4], from: f64, held: bool) { + let Some(now) = finite_frame(app) else { return }; + let Some((cw, ch)) = app.session.active().map(|st| (f64::from(st.doc.size.width), f64::from(st.doc.size.height))) else { return }; + let inside_at_press = kept_inside(rect, from, cw, ch) == Some(rect); + let want = if held || app.ui.tool_options.crop_content_aware || !inside_at_press { + rect + } else { + kept_inside(rect, crate::crop_ui::angle(app), cw, ch).unwrap_or(rect) + }; + if want == now { + return; + } + // Moves the frame's centre and the view together (the box stays put), then sets the size. + let (a, b) = (crate::crop_ui::center(now), crate::crop_ui::center(want)); + shift_image(app, [a[0] - b[0], a[1] - b[1]]); + app.ui.crop_rect = Some(want); +} + /// A live-centred resize ([`live_resize`]): the frame `rect` and handle (`hx`, `hy`) at the press, /// grabbed at document point `start`; the pointer now at `q` (in the press view); the frame's turn /// `deg`, the aspect `ratio` to hold and ⌥ (resize about the centre). diff --git a/crates/ui-egui/src/tools/crop_ui.rs b/crates/ui-egui/src/tools/crop_ui.rs index 3a05ba2cc7..7f738edae2 100644 --- a/crates/ui-egui/src/tools/crop_ui.rs +++ b/crates/ui-egui/src/tools/crop_ui.rs @@ -94,8 +94,9 @@ pub enum CropDrag { MoveImage { start: [f64; 2], rect: [f64; 4], view: Option }, /// Default mode, outside the box: the image turns about the frame's `center` (which stays put /// on screen). `grab` is the pointer's direction from it at the press (radians, in the press - /// `view`), `from` the frame's angle then (degrees). - TurnImage { center: [f64; 2], grab: f64, from: f64, view: Option }, + /// `view`), `from` the frame's angle then (degrees) and `rect` the frame then, which the turn + /// keeps inside the turned image (`crop_mode::keep_inside`). + TurnImage { center: [f64; 2], grab: f64, from: f64, rect: [f64; 4], view: Option }, } /// What the pointer is over, relative to a crop frame. @@ -851,7 +852,7 @@ fn outside_drag(app: &PhotocraftApp, p: [f64; 2], rect: [f64; 4], deg: f64) -> C let c = center(rect); let grab = (p[1] - c[1]).atan2(p[0] - c[0]); if crate::crop_mode::moves_image(app) { - CropDrag::TurnImage { center: c, grab, from: deg, view: crate::crop_mode::press_view(app) } + CropDrag::TurnImage { center: c, grab, from: deg, rect, view: crate::crop_mode::press_view(app) } } else { // Outside the frame a drag turns it, as in Photoshop (#1792). CropDrag::Rotate { center: c, grab, from: deg } @@ -919,13 +920,14 @@ fn update(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) { fn follow_view_drag(app: &mut PhotocraftApp, p: [f64; 2], mods: Modifiers) -> bool { match app.crop.drag { Some(CropDrag::Rotate { center, grab, from }) => app.ui.crop_angle = turned_angle(center, grab, from, p, mods.shift), - Some(CropDrag::TurnImage { center, grab, from, view }) => { + Some(CropDrag::TurnImage { center, grab, from, rect, view }) => { // The image turns with the pointer's sweep on screen, so the frame turns against it. let at = crate::crop_mode::to_press(app, view, p); let turned = from - ((at[1] - center[1]).atan2(at[0] - center[0]) - grab).to_degrees(); if turned.is_finite() { app.ui.crop_angle = normalized_angle(if mods.shift { (turned / 15.0).round() * 15.0 } else { turned }); crate::crop_mode::sync_frame(app); + crate::crop_mode::keep_inside(app, rect, from, mods.command); } } Some(CropDrag::MoveImage { start, rect, view }) => { diff --git a/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs new file mode 100644 index 0000000000..74e7fbbef9 --- /dev/null +++ b/crates/ui-egui/tests/it/crop_turn_keeps_frame_inside.rs @@ -0,0 +1,308 @@ +//! Crop tool, default mode: turning the image keeps the frame inside the turned image (#10869). +//! +//! Measured in the reference application on an 800 × 500 document, turning by a drag outside the +//! frame with Auto Center Preview off: the frame keeps its size where it fits the turned image, +//! else moves the shortest way into it, else shrinks about its centre keeping its proportions. +//! With Ctrl (⌘) held the frame isn't adjusted and the uncovered corners get the Fill. +//! PhotoCraft never adjusted the frame, so ↵ filled the corners with the background colour. +//! +//! Driven as an agent does, through `ui.pointer` (document px as the view showed them at the +//! press). + +use photocraft_ui_egui::PhotocraftApp; +use photocraft_ui_egui::canvas::ViewXform; +use photocraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use photocraft_ui_egui::crop_mode::kept_inside; +use photocraft_ui_egui::crop_ui::{angle, center, corners}; +use serde_json::{Value, json}; + +/// The document's size. +const W: f64 = 800.0; +const H: f64 = 500.0; +/// The whole canvas, the default frame. +const CANVAS: [f64; 4] = [0.0, 0.0, W, H]; + +/// An 800 × 500 white document with a #1050e0 background colour, the Crop tool in the default +/// mode with Auto Center Preview off, and `frame` as the pending crop frame: the default frame +/// when it is the whole canvas, else drawn from its bottom-right corner to its top-left one (a +/// press on the default frame's corner handle would resize it). +fn app(frame: [f64; 4]) -> (PhotocraftApp, egui::Context) { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": W, "height": H, "background": "white"})).expect("new"); + app.run("tools.setColors", json!({"background": "#1050e0"})).expect("colors"); + app.sync_views(); + app.ui.extras.snap = false; + app.ui.view.show.smart_guides = false; + app.ui.tool_options.crop_shield.auto_center_preview = false; + control(&mut app, &ctx, "ui.set", json!({"tool": "crop", "zoom": 1.0, "center": [W / 2.0, H / 2.0]})); + photocraft_ui_egui::crop_ui::ensure_frame(&mut app); + assert!(!app.ui.tool_options.crop_shield.classic_mode, "Photoshop's default mode"); + if frame != CANVAS { + let events = json!([ + {"kind": "down", "x": frame[2], "y": frame[3]}, + {"kind": "move", "x": frame[0], "y": frame[1]}, + {"kind": "up", "x": frame[0], "y": frame[1]}, + ]); + control(&mut app, &ctx, "ui.pointer", json!({"events": events})); + } + assert_eq!(app.ui.crop_rect, Some(frame)); + (app, ctx) +} + +/// Runs one control-channel request, which must succeed. +fn control(app: &mut PhotocraftApp, ctx: &egui::Context, method: &str, params: Value) { + let (req, _rx) = ControlRequest::new(method, params.clone()); + assert!(matches!(handle(app, ctx, &req), Outcome::Done(_)), "{method} {params}"); +} + +/// The point 500 px right of the frame's centre turned `deg` degrees clockwise about it: a press +/// there is outside every frame below, and moving to it from the unturned point turns the image +/// `deg` degrees clockwise. +fn around(frame: [f64; 4], deg: f64) -> [f64; 2] { + let c = center(frame); + let (s, k) = deg.to_radians().sin_cos(); + [c[0] + 500.0 * k, c[1] + 500.0 * s] +} + +/// A turn of the image: a press outside `frame`, then for each `(deg, modifiers)` step a move to +/// `deg` with those modifiers held (one `ui.pointer` call each, so a key can be pressed during +/// the drag); `each` runs after every step. Releases at the last step's point with its modifiers. +fn turn(app: &mut PhotocraftApp, ctx: &egui::Context, frame: [f64; 4], steps: &[(f64, Value)], mut each: impl FnMut(&PhotocraftApp, f64)) { + let p = around(frame, 0.0); + control(app, ctx, "ui.pointer", json!({"events": [{"kind": "down", "x": p[0], "y": p[1]}]})); + for (deg, mods) in steps { + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "move", "x": p[0], "y": p[1]}]})); + each(app, *deg); + } + let (deg, mods) = steps.last().expect("a step"); + let p = around(frame, *deg); + control(app, ctx, "ui.pointer", json!({"modifiers": mods, "events": [{"kind": "up", "x": p[0], "y": p[1]}]})); +} + +/// No modifier held. +fn plain() -> Value { + json!({}) +} + +/// The pending frame. +fn frame(app: &PhotocraftApp) -> [f64; 4] { + app.ui.crop_rect.expect("a frame") +} + +/// The pending frame's corners, turned with it, all lie on the canvas. +fn inside(app: &PhotocraftApp) -> bool { + corners(frame(app), angle(app)).iter().all(|c| (-1e-6..=W + 1e-6).contains(&c[0]) && (-1e-6..=H + 1e-6).contains(&c[1])) +} + +/// Where the frame's centre shows on screen. +fn on_screen(app: &PhotocraftApp) -> egui::Pos2 { + let c = center(frame(app)); + ViewXform::active(app).expect("a view").to_screen(c[0] as f32, c[1] as f32) +} + +/// ↵: commits the crop and returns the document's new size. +fn committed(app: &mut PhotocraftApp) -> (u32, u32) { + photocraft_ui_egui::canvas::commit_crop(app); + let size = app.session.active().expect("document").doc.size; + (size.width, size.height) +} + +/// The flattened document's four corner pixels and its middle one. +fn corners_and_middle(app: &PhotocraftApp) -> [[f32; 4]; 5] { + let doc = &app.session.active().expect("document").doc; + let flat = photocraft_compose::flatten(doc); + let (w, h) = (doc.size.width as usize, doc.size.height as usize); + let px = |x: usize, y: usize| flat.px[y * w + x]; + [px(0, 0), px(w - 1, 0), px(w - 1, h - 1), px(0, h - 1), px(w / 2, h / 2)] +} + +/// The background colour #1050e0 rather than the white image: its red is 16/255. A corner that +/// the turned image covers only in part still has far more red than that. +fn background(p: [f32; 4]) -> bool { + p[0] < 0.5 && (p[2] - 224.0 / 255.0).abs() < 0.02 +} + +/// White: the image. +fn white(p: [f32; 4]) -> bool { + p[..3].iter().all(|&v| v > 0.98) +} + +/// The frame's angle is `want` degrees, up to the `f32` rounding of the view the pointer is read +/// through. +fn near_angle(deg: f64, want: f64) -> bool { + (deg - want).abs() < 1e-4 +} + +/// The size of the largest frame of the canvas's proportions that fits inside the canvas turned +/// `deg` degrees. +fn fitted_size(deg: f64) -> (f64, f64) { + let (s, c) = deg.to_radians().sin_cos(); + let (s, c) = (s.abs(), c.abs()); + let k = (W / (W * c + H * s)).min(H / (W * s + H * c)); + (W * k, H * k) +} + +/// The issue's first two rows: the whole-canvas frame shrinks about its centre, keeping its +/// proportions, to the largest size that fits (687 × 429 measured at 6.3°, 580 × 362 at the 15° +/// that ⇧ snaps to), live while dragging, with the box put on screen. ↵ crops to it and no corner +/// shows the background colour. +#[test] +fn turning_the_whole_canvas_frame_shrinks_it_inside_the_turned_image() { + for (deg, shift, measured) in [(6.3, false, (687.0, 429.0)), (13.0, true, (580.0, 362.0))] { + let what = format!("turned {deg}°, shift {shift}"); + let (mut app, ctx) = app(CANVAS); + let screen = on_screen(&app); + // ⇧ pressed after the press, as measured: the turn snaps to 15°. + let steps = [(deg / 2.0, plain()), (deg, json!({"shift": shift}))]; + turn(&mut app, &ctx, CANVAS, &steps, |app, step| { + assert!(inside(app), "{what}: at {step}° the frame {:?} turned {} leaves the image", frame(app), angle(app)); + assert!(on_screen(app).distance(screen) < 0.01, "{what}: at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let turned = if shift { 15.0 } else { deg }; + assert!(near_angle(angle(&app), -turned), "{what}: the frame's angle is {}", angle(&app)); + let (w, h) = fitted_size(angle(&app)); + let f = frame(&app); + let (fw, fh) = (f[2] - f[0], f[3] - f[1]); + assert!((fw - w).abs() < 1e-6 && (fh - h).abs() < 1e-6, "{what}: {fw} × {fh}, want {w} × {h}"); + assert!((fw - measured.0).abs() <= 3.0 && (fh - measured.1).abs() <= 3.0, "{what}: {fw} × {fh} is far from the reference's {measured:?}"); + let c = center(f); + assert!((c[0] - W / 2.0).abs() < 1e-6 && (c[1] - H / 2.0).abs() < 1e-6, "{what}: centred, not at {c:?}"); + let (cw, ch) = committed(&mut app); + assert!((f64::from(cw) - w).abs() <= 1.0 && (f64::from(ch) - h).abs() <= 1.0, "{what}: committed {cw} × {ch}"); + for p in corners_and_middle(&app) { + assert!(!background(p), "{what}: a corner shows the background colour: {p:?}"); + } + assert!(white(corners_and_middle(&app)[4]), "{what}: the middle is the image"); + } +} + +/// The issue's Ctrl row: with Ctrl (⌘) held the frame keeps the whole canvas and ↵ fills the +/// corners the turn uncovered. Pressed part way through the turn, Ctrl gives the press frame back +/// at once. +#[test] +fn with_command_held_the_turn_keeps_the_frame() { + for late in [false, true] { + let (mut app, ctx) = app(CANVAS); + let first = if late { plain() } else { json!({"command": true}) }; + turn(&mut app, &ctx, CANVAS, &[(3.0, first), (6.0, json!({"command": true}))], |app, step| { + if late && step == 3.0 { + assert!(frame(app) != CANVAS && inside(app), "Ctrl not held yet: the frame fits, {:?}", frame(app)); + } else { + assert_eq!(frame(app), CANVAS, "late {late}: at {step}°"); + } + }); + assert!(near_angle(angle(&app), -6.0), "late {late}: {}", angle(&app)); + assert_eq!(committed(&mut app), (800, 500), "late {late}"); + let [corner @ .., middle] = corners_and_middle(&app); + for p in corner { + assert!(background(p), "late {late}: an uncovered corner is {p:?}"); + } + assert!(white(middle), "late {late}: the middle is the image"); + } +} + +/// The issue's fourth row: a 300 × 201 frame at (100, 99) still lies inside the image turned +/// 11.3° about its centre, so it stays exactly as it was. +#[test] +fn a_frame_that_fits_the_turned_image_keeps_its_size_and_place() { + let start = [100.0, 99.0, 400.0, 300.0]; + let (mut app, ctx) = app(start); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.3, plain())], |app, step| { + assert_eq!(frame(app), start, "at {step}°"); + }); + assert!(near_angle(angle(&app), -11.3), "{}", angle(&app)); + assert_eq!(committed(&mut app), (300, 201)); + for p in corners_and_middle(&app) { + assert!(white(p), "{p:?}"); + } +} + +/// The issue's last row: a 300 × 200 frame in the top-left corner, turned 11.1°, keeps its size +/// and moves the shortest way into the turned image: its turned corners end up touching the +/// canvas's left and top edges, not further in. The box stays put on screen while the image +/// slides under it. +#[test] +fn a_frame_in_the_corner_moves_in_without_shrinking() { + let start = [0.0, 0.0, 300.0, 200.0]; + let (mut app, ctx) = app(start); + let screen = on_screen(&app); + turn(&mut app, &ctx, start, &[(5.0, plain()), (11.1, plain())], |app, step| { + assert!(inside(app), "at {step}° the frame {:?} leaves the image", frame(app)); + assert!(on_screen(app).distance(screen) < 0.01, "at {step}° the box moved on screen to {:?}", on_screen(app)); + }); + let f = frame(&app); + assert!(((f[2] - f[0]) - 300.0).abs() < 1e-9 && ((f[3] - f[1]) - 200.0).abs() < 1e-9, "the size is kept: {f:?}"); + let turned = corners(f, angle(&app)); + let left = turned.iter().map(|c| c[0]).fold(f64::INFINITY, f64::min); + let top = turned.iter().map(|c| c[1]).fold(f64::INFINITY, f64::min); + assert!(left.abs() < 1e-9 && top.abs() < 1e-9, "moved the shortest way: the turned frame's left {left}, top {top}"); + assert_eq!(committed(&mut app), (300, 200)); + for p in corners_and_middle(&app) { + assert!(!background(p), "a corner shows the background colour: {p:?}"); + } +} + +/// Every step of the turn starts from the frame at the press, so turning back within the same +/// drag gives the whole frame back instead of keeping the smallest size it passed through. +#[test] +fn turning_back_gives_the_frame_back() { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(10.0, plain()), (0.0, plain())], |_, _| {}); + assert_eq!((frame(&app), angle(&app)), (CANVAS, 0.0)); +} + +/// A second turn starts from the frame the first one fitted, whose turned corners touch the +/// canvas's edges: turned further it shrinks again, turned back upright it stays as it is. +#[test] +fn a_second_turn_starts_from_the_fitted_frame() { + for second in [10.0, -6.3] { + let (mut app, ctx) = app(CANVAS); + turn(&mut app, &ctx, CANVAS, &[(6.3, plain())], |_, _| {}); + let first = frame(&app); + turn(&mut app, &ctx, first, &[(second, plain())], |_, _| {}); + let f = frame(&app); + let what = format!("then {second}°: {f:?} turned {}", angle(&app)); + assert!(near_angle(angle(&app), -(6.3 + second)), "{what}"); + assert!(inside(&app), "{what}"); + if second > 0.0 { + let want = fitted_size(angle(&app)); + assert!(((f[2] - f[0]) - want.0).abs() < 1e-6 && ((f[3] - f[1]) - want.1).abs() < 1e-6, "{what}: want {want:?}"); + } else { + assert_eq!(f, first, "{what}"); + } + } +} + +/// A frame `kept_inside` fitted is left exactly as it is at the same angle, at every tenth of a +/// degree up to 90°. The fitted frame's turned corners touch the canvas's edges, and at many +/// angles rounding puts them a hair past: without the fit's tolerance such a frame would count as +/// reaching past the canvas, and a second turn would no longer keep it inside. +#[test] +fn a_fitted_frame_fits_at_its_own_angle() { + let off_centre = [0.0, 0.0, 300.0, 200.0]; + for i in 1..900 { + let deg = f64::from(i) / 10.0; + for start in [CANVAS, off_centre] { + let f = kept_inside(start, deg, W, H).expect("a frame"); + assert_eq!(kept_inside(f, deg, W, H), Some(f), "{start:?} at {deg}°"); + } + } +} + +/// Not measured, so they keep today's behaviour: with Fill set to Content-Aware Fill, with a +/// frame already reaching past the canvas at the press, and in Classic Mode (where the frame +/// turns over the image) the frame keeps its size and place. +#[test] +fn content_aware_fill_a_frame_past_the_canvas_and_classic_mode_keep_the_frame() { + let past = [-50.0, 100.0, 300.0, 300.0]; + for (what, start) in [("content-aware", CANVAS), ("past the canvas", past), ("classic", CANVAS)] { + let (mut app, ctx) = app(start); + app.ui.tool_options.crop_content_aware = what == "content-aware"; + app.ui.tool_options.crop_shield.classic_mode = what == "classic"; + turn(&mut app, &ctx, start, &[(6.3, plain())], |_, _| {}); + assert!(angle(&app).abs() > 6.0, "{what}: turned, {}", angle(&app)); + assert_eq!(frame(&app), start, "{what}"); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 22a28c8477..9d9116dbbc 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_turn_keeps_frame_inside; mod custom_print_paper; mod display_shadows; mod drag_preview_canvas;