mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
* Paste: keep the pasted image on the canvas (#6759) Paste centred the clipboard image on the view centre with no clamp, so an image that fit the canvas could land partly off it, and one larger than the canvas could leave blank edges. Now, per axis, an image that fits stays wholly inside the canvas, as close to the view centre as it can, and one larger than the canvas starts at the canvas's top-left edge, as in Photoshop. Paste Into / Outside still centre on the selection ("clampToCanvas": false). Centres are computed edge by edge in f64, so extreme coordinates no longer overflow. * Paste: doc comments for Paste Into and the paste placement tests --------- Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Brandon Thomas
parent
6e52641508
commit
15db2d8560
@@ -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<CommandSpec> {
|
||||
&["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.
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user