diff --git a/crates/engine/src/edit_cmds.rs b/crates/engine/src/edit_cmds.rs index 1ae19fcdad..1a6c3a4072 100644 --- a/crates/engine/src/edit_cmds.rs +++ b/crates/engine/src/edit_cmds.rs @@ -450,15 +450,35 @@ fn paste_layers(s: &mut Session, layers: &Document, dx: i32, dy: i32) -> Result< /// How far a paste moves the clipboard pixels: not at all in place (or when they lie on the /// canvas and no `center` is given), else onto `center` (the view centre from the UI) or the -/// canvas centre. +/// canvas centre, then kept on the canvas as Photoshop keeps it: pixels that fit stay wholly +/// inside, larger ones start at the canvas's top-left corner (#6759). `"clampToCanvas": false` +/// skips that, for Paste Into / Outside, which centre on the selection. pub(crate) fn paste_offset(clip: &Clip, canvas: Rect, p: &Value, in_place: bool) -> (i32, i32) { if in_place || (clip.bounds.intersect(&canvas) == clip.bounds && p.get("center").is_none()) { return (0, 0); } let c = p.get("center").and_then(Value::as_array).filter(|a| a.len() >= 2).map(|a| (a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0))); - let (cx, cy) = c.unwrap_or(((f64::from(canvas.x0) + f64::from(canvas.x1)) / 2.0, (f64::from(canvas.y0) + f64::from(canvas.y1)) / 2.0)); + let (cx, cy) = c.unwrap_or((mid(canvas.x0, canvas.x1), mid(canvas.y0, canvas.y1))); let b = clip.bounds; - ((cx - (f64::from(b.x0) + f64::from(b.x1)) / 2.0).round() as i32, (cy - (f64::from(b.y0) + f64::from(b.y1)) / 2.0).round() as i32) + let (dx, dy) = ((cx - mid(b.x0, b.x1)).round(), (cy - mid(b.y0, b.y1)).round()); + if p.get("clampToCanvas").and_then(Value::as_bool) == Some(false) || b.is_empty() { + return (dx as i32, dy as i32); + } + (clamp_shift(dx, b.x0, b.x1, canvas.x0, canvas.x1), clamp_shift(dy, b.y0, b.y1, canvas.y0, canvas.y1)) +} + +/// The midpoint of `a..b`, each edge converted before adding so extreme coordinates can't overflow. +fn mid(a: i32, b: i32) -> f64 { + (f64::from(a) + f64::from(b)) / 2.0 +} + +/// Shift `d` of the span `b0..b1` along one axis, changed as little as needed to keep the span +/// inside `c0..c1` when it fits; a span longer than the canvas starts at `c0`. +fn clamp_shift(d: f64, b0: i32, b1: i32, c0: i32, c1: i32) -> i32 { + let (b0, b1, c0, c1) = (i64::from(b0), i64::from(b1), i64::from(c0), i64::from(c1)); + // `as` saturates a NaN or out-of-range shift, then the clamp brings it into range. + let start = if b1 - b0 <= c1 - c0 { b0.saturating_add(d as i64).clamp(c0, c1 - (b1 - b0)) } else { c0 }; + i32::try_from(start - b0).unwrap_or(if start > b0 { i32::MAX } else { i32::MIN }) } pub(crate) fn shifted(surface: &Surface, dx: i32, dy: i32) -> Surface { @@ -994,7 +1014,7 @@ pub fn specs() -> Vec { &["Edit"], Some("Cmd+V"), concat!( - r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"#, + r#"{"center":[x,y]? (view centre; default keeps the position when it overlaps the canvas),"clampToCanvas":bool=true (keep the paste on the canvas, or at its top-left when larger),"#, paste_target!(), "} (with no document open: a new document from the clipboard)" ), @@ -1288,6 +1308,78 @@ mod tests { assert_eq!(s.active().unwrap().doc.layers.len(), 4); } + /// A `w`×`h` document with an opaque `cw`×`ch` image from another app on the clipboard. + fn external_clip_session(w: i32, h: i32, cw: i32, ch: i32) -> Session { + let mut s = Session::new(); + s.execute("file.new", json!({"width": w, "height": h})).unwrap(); + let mut surface = Surface::new(PixelFormat::RGBA8); + surface.fill_rect(Rect::new(0, 0, cw, ch), &[1.0, 0.0, 0.0, 1.0]); + s.clipboard = Some(Clip { surface, bounds: Rect::new(0, 0, cw, ch), layers: None, resolution: None }); + s + } + + /// #6759: wherever the view is centred, a paste that fits the canvas stays wholly on it. + #[test] + fn paste_that_fits_stays_on_the_canvas() { + for (w, h, center, want) in [ + (60, 100, [40.0, 45.0], Rect::new(0, 0, 60, 100)), + (60, 100, [-500.0, 900.0], Rect::new(0, 0, 60, 100)), + (100, 100, [10.0, 80.0], Rect::new(0, 0, 60, 100)), + (100, 100, [95.0, 50.0], Rect::new(40, 0, 100, 100)), + (100, 120, [50.0, 60.0], Rect::new(20, 10, 80, 110)), + ] { + let mut s = external_clip_session(w, h, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), want, "{w}x{h} canvas, view centre {center:?}"); + } + } + + /// #6759: a paste larger than the canvas starts at its top-left corner, wherever the view is. + #[test] + fn paste_larger_than_the_canvas_starts_at_its_top_left() { + for center in [[0.0, 0.0], [25.0, 20.0], [1000.0, 1000.0], [-1000.0, 30.0]] { + let mut s = external_clip_session(50, 40, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(0, 0, 60, 100), "view centre {center:?}"); + } + // Wider but not taller: at the left edge, and wholly inside vertically. + for (center, want) in + [([500.0, 500.0], Rect::new(0, 100, 60, 200)), ([-500.0, -500.0], Rect::new(0, 0, 60, 100)), ([25.0, 120.0], Rect::new(0, 70, 60, 170))] + { + let mut s = external_clip_session(50, 200, 60, 100); + s.execute("edit.paste", json!({ "center": center })).unwrap(); + assert_eq!(active_bounds(&s), want, "view centre {center:?}"); + } + } + + /// Paste Into centres on the selection even where that runs off the canvas. + #[test] + fn paste_into_is_not_clamped_to_the_canvas() { + let mut s = session(); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 40, "height": 20})).unwrap(); + s.execute("edit.copy", json!({})).unwrap(); + s.execute("select.rect", json!({"x": 90, "y": 90, "width": 10, "height": 10})).unwrap(); + s.execute("edit.pasteSpecial.pasteInto", json!({})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(75, 85, 115, 105)); + } + + /// The clamp never overflows or panics: NaN, infinite and `i32`-limit shifts saturate (Rule 9). + #[test] + fn paste_offset_clamp_survives_extreme_coordinates() { + assert_eq!(clamp_shift(f64::NAN, 0, 10, 0, 100), 0); + assert_eq!(clamp_shift(f64::INFINITY, 0, 10, 0, 100), 90); + assert_eq!(clamp_shift(f64::NEG_INFINITY, i32::MIN, i32::MAX, 0, 1), i32::MAX); + assert_eq!(clamp_shift(1e30, i32::MIN, i32::MIN + 1, i32::MAX - 1, i32::MAX), i32::MAX); + let clip = Clip { + surface: Surface::new(PixelFormat::RGBA8), + bounds: Rect::new(i32::MAX - 2, i32::MAX - 2, i32::MAX, i32::MAX), + layers: None, + resolution: None, + }; + let (dx, dy) = paste_offset(&clip, Rect::new(i32::MIN, i32::MIN, i32::MIN + 4, i32::MIN + 4), &json!({}), false); + assert_eq!((dx, dy), (i32::MIN, i32::MIN), "an unrepresentable shift saturates instead of wrapping"); + } + #[test] fn paste_with_malformed_center_does_not_panic() { // A short/empty `center` array must not panic (Rule 9) — it falls back to the canvas centre. diff --git a/crates/engine/src/extra_cmds.rs b/crates/engine/src/extra_cmds.rs index 4e2a265ab9..c7dc306d1a 100644 --- a/crates/engine/src/extra_cmds.rs +++ b/crates/engine/src/extra_cmds.rs @@ -336,6 +336,9 @@ fn can_reselect(s: &Session) -> std::result::Result<(), String> { reselect_target(s).map(|_| ()).ok_or_else(|| "there is no selection to restore".into()) } +/// Edit › Paste Special › Paste Into (or Outside): the clipboard centred on the selection, not +/// clamped to the canvas, as a new layer masked to the selection (or its inverse), or into the +/// targeted mask or channel. One history step. fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { let d = s.active().ok_or(EngineError::NoDocument)?; let selection = d.doc.selection.clone().ok_or(EngineError::Other("Paste Into needs a selection".into()))?; @@ -353,7 +356,8 @@ fn paste_into(s: &mut Session, p: &Value, outside: bool) -> Result { selection }; let label = if outside { "Paste Outside" } else { "Paste Into" }; - let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0]}); + // Centred on the selection, even where that runs off the canvas (not clamped like a paste). + let mut params = json!({"center": [(b.x0 + b.x1) as f64 / 2.0, (b.y0 + b.y1) as f64 / 2.0], "clampToCanvas": false}); if let Some(c) = p.get("center") { params["center"] = c.clone(); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cd9216893a..886fffecaf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -3606,10 +3606,11 @@ mod clipboard_tests { app } + /// Paste is enabled from the clipboard service alone (no read); the explicit paste imports + /// the OS image, once, and centres it in the view like Photoshop does for foreign clipboard + /// content. #[test] fn external_clipboard_enables_and_centres_paste() { - // Paste is enabled from the clipboard service alone (no read); the explicit paste imports - // the OS image, once, and centres it like Photoshop does for foreign clipboard content. let (os, reads) = (OsClip::default(), Arc::default()); let mut app = app_with_os_clipboard(&os, &reads); *os.lock().unwrap() = Some((4, 4, [255u8, 0, 0, 255].repeat(16))); @@ -3617,8 +3618,9 @@ mod clipboard_tests { assert!(crate::menus::is_enabled(&app, "edit.paste")); assert!(crate::menus::is_enabled(&app, "edit.pasteSpecial.pasteInPlace")); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 0, "enablement never reads the clipboard"); + app.ui.views[0].center = [40.0, 20.0]; let r = crate::menus::invoke(&mut app, &egui::Context::default(), "edit.paste", serde_json::json!({})).unwrap(); - assert_ne!(r["offset"], serde_json::json!([0, 0]), "external image is centred: {r}"); + assert_eq!(r["offset"], serde_json::json!([38, 18]), "external image is centred in the view: {r}"); assert_eq!(reads.load(std::sync::atomic::Ordering::SeqCst), 1, "one read per explicit paste"); // Without a clipboard service Paste greys until something is copied in the app. let mut plain = PhotocraftApp::new(Session::new(), Services::default());