diff --git a/contributors/people.toml b/contributors/people.toml index 1e61ff00b42..618dda5608a 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -325,3 +325,7 @@ sync_github_name = true [people.rmsff] real_name = "Serikov Roman" sync_github_name = false + +[people.tomgardenart] +display_name = "Nostella" +sync_github_name = false diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 0700fb30c8d..1406f59ba8d 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -44,6 +44,16 @@ pub struct CutParts { } impl CutParts { + /// Selected pixels in source-document coordinates, including soft coverage. + pub(crate) fn selected_pixels(&self) -> &Surface { + &self.piece + } + + /// Whether the floating piece contains a layer mask rather than layer pixels. + pub(crate) fn is_mask(&self) -> bool { + self.mask + } + /// Split `layer` of `doc` by its selection. With `copy` the selected pixels stay in the layer /// too (the piece is a duplicate). Without, they leave a transparent hole, or on the Background, /// which can't hold transparency, one filled with `background` (Photoshop). Errors without a @@ -340,7 +350,7 @@ pub fn drop_floating(s: &mut Session) -> Result { /// Commands that leave a floating piece floating: its own, and ones that don't touch documents. fn keeps_floating(id: &str) -> bool { - matches!(id, "select.float" | "select.drop" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean") + matches!(id, "select.float" | "select.drop" | "layer.copyToDocument" | "brush.mixer.sample" | "brush.mixer.load" | "brush.mixer.clean") || ["view.", "window.", "help.", "prefs."].iter().any(|p| id.starts_with(p)) } diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs index 0c12d5a21fb..f8a4f06276d 100644 --- a/crates/engine/src/cmd/layer_copy_cmds.rs +++ b/crates/engine/src/cmd/layer_copy_cmds.rs @@ -221,6 +221,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { } let (from, to) = (&s.documents()[src], &s.documents()[dest]); let (sdoc, ddoc) = (from.doc.clone(), to.doc.clone()); + let floating = crate::float_cmds::floating(from).cloned(); let ids: Vec = match p.get("layers") { None => from.selected_layers(), Some(v) => v @@ -236,6 +237,14 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad("no layers to copy")); } + let selection_only = match p.get("selectionOnly") { + None => false, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("`selectionOnly` must be a boolean")), + }; + if selection_only && (ids.len() != 1 || sdoc.selection.is_none()) { + return Err(bad("copying selected pixels requires a selection and one pixel layer")); + } for (doc, role) in [(&sdoc, "source"), (&ddoc, "destination")] { if !layered(doc.mode) { return Err(EngineError::Other(format!("layers can't be copied with a {:?} {role} document", doc.mode))); @@ -250,7 +259,24 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { copies.text_styles.clone_from(&sdoc.text_styles); for id in &ids { let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?; - let mut l = src_layer.duplicate(); + let mut l = if selection_only { + let parts = match floating.as_ref().filter(|f| f.layer == *id) { + Some(f) if f.parts.is_mask() => return Err(bad("a floating layer mask cannot be transferred as selected pixels")), + Some(f) => f.parts.clone(), + None => std::sync::Arc::new(crate::float_cmds::CutParts::new(&sdoc, *id, true, s.tools.background, false)?), + }; + if parts.selected_pixels().content_bounds().is_empty() { + return Err(bad("the selected pixels are empty")); + } + let mut layer = Layer::new(src_layer.name.clone(), LayerContent::Raster(parts.selected_pixels().clone())); + layer.opacity = src_layer.opacity; + if let Some(f) = floating.as_ref().filter(|f| f.layer == *id) { + crate::commands::translate_layer(&sdoc, &mut layer, f.offset.0, f.offset.1); + } + layer + } else { + src_layer.duplicate() + }; // A dragged Background arrives as an ordinary layer (the destination keeps its own). if sdoc.is_background(*id) { l.name = ddoc.next_layer_name("Layer"); @@ -399,7 +425,7 @@ pub fn specs() -> Vec { label: "Copy Layers to Document", menu: &[], shortcut: None, - params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##, + params: r##"{"document":index (destination),"layers":[id,…]?=the source's selected layers,"source":index?=active,"selectionOnly":bool=false (copy selected pixels from one raster layer),"center":bool=false (centre on the destination's canvas),"at":[x,y]? (centre the copies on this point),"offset":[dx,dy]?=[0,0] (from their place in the source),"name":str? (one layer: the copy's name)}"##, enabled, run: copy_to_document, journal: true, diff --git a/crates/engine/src/cmd/layer_copy_cmds/tests.rs b/crates/engine/src/cmd/layer_copy_cmds/tests.rs index 5f82c1c4bde..ba97625696e 100644 --- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs @@ -28,6 +28,53 @@ fn copied(r: &Value) -> LayerId { LayerId(r["layers"][0].as_u64().unwrap()) } +#[test] +fn selection_only_copy_preserves_source_and_soft_coverage_across_documents() { + for depth in [8, 16, 32] { + let (mut s, paint) = two_docs(40, 30, depth); + s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + s.execute("layer.setProps", json!({"layer": paint.0, "opacity": 0.4})).unwrap(); + std::sync::Arc::make_mut(&mut s.active_mut().unwrap().doc).selection.as_mut().unwrap().write_pixel(7, 8, &[0.5]); + let before = doc(&s, 0).clone(); + let r = s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true, "offset": [10, 5]})).unwrap(); + let id = copied(&r); + assert_eq!(bounds(&s, 1, id), Rect::new(17, 13, 20, 17)); + assert_eq!(*doc(&s, 0), before); + let pixel = doc(&s, 1).layer(id).unwrap().surface().unwrap().rgba(17, 13); + assert!((doc(&s, 1).layer(id).unwrap().opacity - 0.4).abs() < 1e-6); + assert_eq!(&pixel[..3], &[1.0, 0.0, 0.0]); + assert!((pixel[3] - 0.5).abs() <= 1.0 / 255.0, "soft selection coverage survives depth conversion"); + assert!(s.undo()); + assert_eq!(doc(&s, 1).layers.len(), 1); + assert_eq!(*doc(&s, 0), before); + } +} + +#[test] +fn selection_only_copy_rejects_a_floating_layer_mask() { + let (mut s, paint) = two_docs(40, 30, 8); + s.edit("mask", |doc, _| { + doc.layer_mut(paint).unwrap().mask = Some(photocraft_doc::LayerMask::reveal_all()); + Ok(()) + }) + .unwrap(); + s.execute("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + s.execute("select.float", json!({"dx": 2, "dy": 0, "copy": true, "target": "mask"})).unwrap(); + let source = doc(&s, 0).clone(); + assert!(s.execute("layer.copyToDocument", json!({"source": 0, "document": 1, "layers": [paint.0], "selectionOnly": true})).is_err()); + assert_eq!(*doc(&s, 0), source); + assert_eq!(doc(&s, 1).layers.len(), 1); +} + +#[test] +fn selection_only_copy_rejects_missing_selection_and_wrong_parameter_type() { + let (mut s, _) = two_docs(40, 30, 8); + for option in [json!(true), json!("yes")] { + assert!(s.execute("layer.copyToDocument", json!({"document": 1, "selectionOnly": option})).is_err()); + assert_eq!(doc(&s, 1).layers.len(), 1); + } +} + fn bounds(s: &Session, i: usize, id: LayerId) -> Rect { crate::layer_multi_cmds::layer_bounds(doc(s, i).layer(id).unwrap()).unwrap() } diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 11bbf564bbc..cd21657bec1 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -33,6 +33,9 @@ pub struct Drag { /// The pointer, before snapping, has been half a pixel or more from the press: the gesture is /// not a click even if it comes back there (`tool_input::selection_release`, #11392). pub left_press: bool, + /// Restore a pre-existing floating piece if this Move drag becomes a copy + /// into another document; a fresh float belongs only to this gesture. + pub transfer_floating_before: Option, /// The last ⇧-locked offset of a selection-tool drag of the selection (`move_mods`, #6231); /// keeps the locked direction stable between events. The Move tool's own drags use `move_mods`. pub sel_axis: Option<[f64; 2]>, @@ -64,6 +67,7 @@ impl Drag { reposition: false, sel_move: None, left_press: false, + transfer_floating_before: None, sel_axis: None, lasso: None, vector_owner: None, diff --git a/crates/ui-egui/src/canvas/tabs.rs b/crates/ui-egui/src/canvas/tabs.rs index 722f4f2bb36..d704efad056 100644 --- a/crates/ui-egui/src/canvas/tabs.rs +++ b/crates/ui-egui/src/canvas/tabs.rs @@ -9,6 +9,7 @@ pub fn document_area(app: &mut PhotocraftApp, ui: &mut egui::Ui) { return; } crate::distort_ui::drop_foreign(app); + crate::layer_transfer::prepare(app, ui.ctx()); documents(app, ui); crate::layer_transfer::finish(app, ui.ctx()); // Once a frame, not per document view: the right-click or options-bar Brush Preset picker. diff --git a/crates/ui-egui/src/canvas/tool_input.rs b/crates/ui-egui/src/canvas/tool_input.rs index 7a359c8a286..9085702e8a7 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -306,9 +306,11 @@ fn handle_move_pointer(app: &mut PhotocraftApp, ev: ToolEvent, tool: Tool, mods: // With a selection: cut the selected pixels (⌥ copies them) and drag them as a floating // piece, from anywhere, as a marquee ⌘-drag does. if crate::move_ui::moves_selected_pixels_with(app, tool) { + let before = app.session.active().and_then(photocraft_engine::float_cmds::floating).cloned(); if crate::move_ui::float_selected_for_drag(app, mods.alt) { let mut d = Drag::new(tool, [x, y], vec![[x, y, pressure as f64]], mods, false); d.sel_move = Some(true); + d.transfer_floating_before = before; app.drag = Some(d); } return true; diff --git a/crates/ui-egui/src/canvas/view_xform.rs b/crates/ui-egui/src/canvas/view_xform.rs index 3a0b42ca4ec..d4961472d6d 100644 --- a/crates/ui-egui/src/canvas/view_xform.rs +++ b/crates/ui-egui/src/canvas/view_xform.rs @@ -36,7 +36,7 @@ impl ViewXform { ) } - pub(super) fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self { + pub(crate) fn pixel_aligned_for(self, app: &PhotocraftApp, ppp: f32) -> Self { // Default Crop counter-moves the frame and camera with fractional pointer deltas. // Preserve that continuous mapping so its fixed frame does not wobble during a drag. if crate::crop_mode::moves_image(app) { self } else { self.pixel_aligned(ppp) } diff --git a/crates/ui-egui/src/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index 7534d63fbc1..23b282fc369 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -9,8 +9,7 @@ //! (Photoshop's ⇧-drag). With Window › Arrange tiling several documents, releasing over another //! document's tile drops there. //! -//! A Move-tool drag starting inside a selection or moving a floating piece stays a move: Photoshop -//! would copy just the selected pixels, which is not supported yet. +//! A Move drag with a pixel selection copies only those pixels into a new layer. use photocraft_doc::{DocId, LayerId}; use serde_json::json; @@ -27,6 +26,7 @@ struct Transfer { grab: Option<[f64; 2]>, /// What the ghost by the pointer says. label: String, + selection_only: bool, } fn key() -> egui::Id { @@ -49,7 +49,10 @@ fn begin(app: &PhotocraftApp, ctx: &egui::Context, id: LayerId, grab: Option<[f6 [one] => st.doc.layer(*one).map_or_else(String::new, |l| l.name.clone()), many => crate::i18n::trn(crate::i18n::current(), many.len() as u64, "{n} layer", "{n} layers"), }; - let t = Transfer { source: st.doc.id, layers: layers.iter().map(|l| l.0).collect(), grab, label }; + // Several selected layers move whole; the engine's selected-pixel copy intentionally accepts + // exactly one raster layer. + let selection_only = grab.is_some() && layers.len() == 1 && st.doc.selection.is_some(); + let t = Transfer { source: st.doc.id, layers: layers.iter().map(|l| l.0).collect(), grab, label, selection_only }; ctx.data_mut(|d| d.insert_temp(key(), t)); true } @@ -61,9 +64,9 @@ pub fn begin_from_panel(app: &PhotocraftApp, ctx: &egui::Context, layer: LayerId /// A Move-tool drag of whole layers (not a selection or a floating piece) under way. fn move_drag(app: &PhotocraftApp) -> Option<[f64; 2]> { - let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move.is_none())?; + let d = app.drag.as_ref().filter(|d| d.tool == Tool::Move && d.sel_move != Some(false))?; let st = app.session.active()?; - let plain = app.ui.transform.is_none() && app.guide_drag.is_none() && st.doc.selection.is_none() && st.floating.is_none(); + let plain = app.ui.transform.is_none() && app.guide_drag.is_none() && ((st.doc.selection.is_none() && st.floating.is_none()) || d.sel_move == Some(true)); plain.then_some(d.start) } @@ -79,22 +82,51 @@ pub fn pointer_if_armed(app: &PhotocraftApp, ctx: &egui::Context) -> Option bool { if get(ctx).is_none() { - let Some(grab) = move_drag(app) else { return }; - let Some(active) = app.session.active().and_then(|st| st.active_layer) else { return }; + let Some(grab) = move_drag(app) else { return false }; + let Some(active) = app.session.active().and_then(|st| st.active_layer) else { return false }; if app.session.active_index() == Some(index) { - return; + return false; + } + let Some(previous) = app.drag.as_ref().map(|d| d.transfer_floating_before.clone()) else { return false }; + // An Option-drag of an existing float first drops it and floats a copy. Take that + // compound start back before restoring the captured float, so document, history and + // floating revision remain one consistent source state. + if previous.as_ref().is_some_and(|before| app.session.active().is_some_and(|st| st.revision != before.revision)) { + // The drag's own float is gone or stale: another edit (an opacity key, say) came + // after the drag started, and a rollback would take that edit back instead. The + // drag stays a move in its own document. + if app.session.active().and_then(photocraft_engine::float_cmds::floating).is_none() { + return false; + } + // Nothing is taken from the drag before this can fail (a background job holds the + // history): it goes on as it was, with its ⇧ lock and offset (`move_mods`). + if !app.session.rollback() { + return false; + } } // The Move drag ends without moving anything; an ⌥-drag's copy is taken back. app.drag = None; app.move_preview = None; crate::move_mods::abandon(app); + // The rollback raised the revision; the restored document is the one the float was + // over, so it floats over the new revision (else `floating()` drops it as stale). + if let Some(st) = app.session.active_mut() { + st.floating = previous.map(|f| photocraft_engine::float_cmds::Floating { revision: st.revision, ..f }); + } let active = app.session.active().and_then(|st| st.active_layer).unwrap_or(active); if !begin(app, ctx, active, Some(grab)) { - return; + return false; } } + true +} + +pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) { + if !arm_move_transfer(app, ctx, index) { + return; + } if app.session.active_index() != Some(index) { app.session.set_active(index); app.jobs.focus = None; @@ -131,14 +163,47 @@ pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) { clear(ctx); } -/// The document under screen point `p` that isn't the source, and the document point there -/// (`None` in a tile: tiles keep their own views). +/// Run before canvases process release: a tiled destination must take over the +/// drag before the source can commit it as an ordinary move. +pub(crate) fn prepare(app: &mut PhotocraftApp, ctx: &egui::Context) { + if get(ctx).is_some() { + return; + } + // Do not consume a source drag merely because it passes over another tile. On release this + // runs before canvas input, so a destination can take ownership without the source committing; + // returning to the source still releases as the original move. + if !ctx.input(|i| i.pointer.any_released()) { + return; + } + let Some(pos) = pointer_if_armed(app, ctx) else { return }; + let Some((dest, _)) = target_at(app, pos) else { return }; + if app.session.active_index() != Some(dest) && arm_move_transfer(app, ctx, dest) { + app.session.set_active(dest); + app.jobs.focus = None; + } +} + +/// The document under screen point `p` and the document point there. Arranged tiles use their +/// own view transform, so selected pixels and plain layers both land at the visible drop point. fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; 2]>)> { if !app.last_canvas_rect.contains(p) { return None; } match crate::canvas::arranged_cells(app, app.last_canvas_rect) { - Some(cells) => cells.into_iter().find(|(_, r)| r.contains(p)).map(|(d, _)| (d, None)), + Some(cells) => cells.into_iter().find(|(_, r)| r.contains(p)).and_then(|(d, r)| { + let view = app.ui.views.get(d)?; + let ppp = app.canvas_ppp(); + let xf = crate::canvas::ViewXform { + rect: crate::rulers::content_rect(app, r.shrink(1.0)), + zoom: view.zoom / ppp, + center: view.center, + flip: app.ui.view.flip_horizontal, + rotation: view.rotation, + aspect: app.ui.view.display_aspect(), + } + .pixel_aligned_for(app, ppp); + Some((d, Some(xf.to_doc(p)))) + }), None => Some((app.session.active_index()?, crate::canvas::ViewXform::active(app).map(|xf| xf.to_doc(p)))), } } @@ -166,6 +231,7 @@ fn drop(app: &mut PhotocraftApp, ctx: &egui::Context, dest: usize, at: Option<[f let Some(to) = docs.get(dest).filter(|_| dest != src) else { return }; let same_size = to.doc.size == docs[src].doc.size; let mut p = json!({"document": dest, "source": src, "layers": t.layers}); + p["selectionOnly"] = json!(t.selection_only); match at.filter(|_| !ctx.input(|i| i.modifiers.shift)) { Some(at) => match t.grab { Some(g) => p["offset"] = json!([(at[0] - g[0]).round(), (at[1] - g[1]).round()]), diff --git a/crates/ui-egui/src/panels/layer_transfer/tests.rs b/crates/ui-egui/src/panels/layer_transfer/tests.rs index 889ba295e9d..c088b48940e 100644 --- a/crates/ui-egui/src/panels/layer_transfer/tests.rs +++ b/crates/ui-egui/src/panels/layer_transfer/tests.rs @@ -38,10 +38,16 @@ fn tab(h: &Harness<'_, PhotocraftApp>, name: &str) -> Pos2 { /// Press at `path[0]`, move through the rest (a few frames at each) and release at the last /// point with `release` held. fn drag_along(h: &mut Harness<'_, PhotocraftApp>, path: &[Pos2], release: Modifiers) { + drag_along_pressed(h, path, Modifiers::NONE, release); +} + +/// [`drag_along`], with `press` held from the press on (⌥ for a Move-tool copy). +fn drag_along_pressed(h: &mut Harness<'_, PhotocraftApp>, path: &[Pos2], press: Modifiers, release: Modifiers) { let first = path[0]; + h.event(egui::Event::ModifiersChanged(press)); h.event(egui::Event::PointerMoved(first)); h.run_steps(1); - h.event(egui::Event::PointerButton { pos: first, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: first, button: PointerButton::Primary, pressed: true, modifiers: press }); h.run_steps(1); for w in path.windows(2) { for k in 1..=4 { @@ -72,6 +78,235 @@ fn source_square(h: &Harness<'_, PhotocraftApp>, paint: LayerId) -> photocraft_g photocraft_engine::layer_multi_cmds::layer_bounds(doc.layer(paint).unwrap()).unwrap() } +#[test] +fn move_selection_across_tab_copies_only_selected_pixels_and_keeps_source_history() { + let (mut h, paint) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.run_steps(2); + let before = h.state().session.documents()[0].doc.clone(); + let history = h.state().session.documents()[0].history.entries().len(); + let grab = ViewXform::active(h.state()).unwrap().to_screen(8.0, 10.0); + let over_tab = tab(&h, "dest-doc"); + let drop = h.state().last_canvas_rect.center(); + drag_along(&mut h, &[grab, over_tab, drop], Modifiers::NONE); + let (_, r) = copy(&h); + assert_eq!(r.size(), vec2(3.0, 4.0)); + let at = ViewXform::active(h.state()).unwrap().to_doc(drop); + assert!((r.min.x - (7.0 + at[0] - 8.0) as f32).abs() <= 1.0); + assert_eq!(*h.state().session.documents()[0].doc, *before); + assert_eq!(h.state().session.documents()[0].history.entries().len(), history); + assert_eq!(source_square(&h, paint), photocraft_geom::Rect::new(4, 6, 14, 16)); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1); +} + +#[test] +fn a_selection_dropped_outside_documents_copies_nothing() { + let (mut h, _) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.run_steps(2); + let before = h.state().session.documents()[0].doc.clone(); + let grab = ViewXform::active(h.state()).unwrap().to_screen(8.0, 10.0); + let dest = tab(&h, "dest-doc"); + drag_along(&mut h, &[grab, dest, Pos2::new(-10.0, -10.0)], Modifiers::NONE); + assert_eq!(*h.state().session.documents()[0].doc, *before); + assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1); +} + +#[test] +fn a_selection_can_be_dragged_directly_between_tiled_canvases() { + let (mut h, _) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().ui.view.arrange = "twoUpVertical".into(); + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.run_steps(3); + let before = h.state().session.documents()[0].doc.clone(); + let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap(); + let view = &h.state().ui.views[0]; + let xf = ViewXform { + rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)), + zoom: view.zoom / h.state().ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + }; + let grab = xf.to_screen(8.0, 10.0); + let drop = cells[1].1.center(); + drag_along(&mut h, &[grab, drop], Modifiers::NONE); + assert_eq!(copy(&h).1.size(), vec2(3.0, 4.0)); + assert_eq!(*h.state().session.documents()[0].doc, *before); +} + +#[test] +fn a_plain_layer_can_be_dragged_directly_between_tiled_canvases() { + let (mut h, paint) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().ui.view.arrange = "twoUpVertical".into(); + h.run_steps(3); + let before = source_square(&h, paint); + let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap(); + let view = &h.state().ui.views[0]; + let source_xf = ViewXform { + rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)), + zoom: view.zoom / h.state().ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + }; + let grab = source_xf.to_screen(9.0, 11.0); + let drop = cells[1].1.center(); + drag_along(&mut h, &[grab, drop], Modifiers::NONE); + let (name, copied) = copy(&h); + assert_eq!(name, "paint"); + assert_eq!(copied.size(), vec2(10.0, 10.0)); + assert_eq!(source_square(&h, paint), before, "the tiled transfer preserves the source layer"); +} + +#[test] +fn a_tiled_selection_drag_that_returns_to_its_source_still_moves_there() { + let (mut h, _) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().ui.view.arrange = "twoUpVertical".into(); + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.run_steps(3); + let before = h.state().session.documents()[0].doc.clone(); + let history = h.state().session.documents()[0].history.past_len(); + let cells = crate::canvas::arranged_cells(h.state(), h.state().last_canvas_rect).unwrap(); + let view = &h.state().ui.views[0]; + let ppp = h.state().canvas_ppp(); + let source_xf = ViewXform { + rect: crate::rulers::content_rect(h.state(), cells[0].1.shrink(1.0)), + zoom: view.zoom / ppp, + center: view.center, + flip: false, + rotation: view.rotation, + aspect: 1.0, + } + .pixel_aligned_for(h.state(), ppp); + let grab = source_xf.to_screen(8.0, 10.0); + let moved = source_xf.to_screen(13.0, 10.0); + assert!(cells[0].1.contains(moved), "release {moved:?} is outside source cell {:?}", cells[0].1); + drag_along(&mut h, &[grab, cells[1].1.center(), moved], Modifiers::NONE); + let state = h.state(); + assert_eq!(*state.session.documents()[0].doc, *before, "a selection move remains floating until it is dropped"); + let floating = state.session.documents()[0].floating.as_ref().expect("returning to the source preserves and releases the original move drag"); + assert!(floating.moved); + assert_ne!(floating.offset, (0, 0)); + assert_eq!(state.session.documents()[0].history.past_len(), history); + assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1); +} + +#[test] +fn an_option_drag_of_a_moved_float_onto_a_tab_keeps_the_source_float() { + let (mut h, _) = harness(); + h.state_mut().ui.tool = Tool::Move; + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.state_mut().run("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + h.run_steps(2); + let source = &h.state().session.documents()[0]; + let (before, history) = (source.doc.clone(), source.history.past_len()); + assert!(photocraft_engine::float_cmds::floating(source).is_some_and(|f| f.offset == (5, 0))); + let grab = ViewXform::active(h.state()).unwrap().to_screen(13.0, 10.0); + let over_tab = tab(&h, "dest-doc"); + let drop = h.state().last_canvas_rect.center(); + drag_along_pressed(&mut h, &[grab, over_tab, drop], Modifiers::ALT, Modifiers::NONE); + assert_eq!(copy(&h).1.size(), vec2(3.0, 4.0)); + let source = &h.state().session.documents()[0]; + assert_eq!(*source.doc, *before); + assert_eq!(source.history.past_len(), history); + // The rollback of the ⌥-drag's drop-and-copy raised the revision: the restored float must + // still count as floating over it, or the earlier move is lost. + let float = photocraft_engine::float_cmds::floating(source).expect("the moved float survives the transfer"); + assert_eq!(float.offset, (5, 0)); +} + +/// A moved float in "source-doc", the Move tool, and an ⌥-press on it, moved a little so the +/// drag has dropped the float and floats its copy. Returns the tab of "dest-doc". +fn option_drag_of_a_moved_float(h: &mut Harness<'_, PhotocraftApp>) -> Pos2 { + h.state_mut().ui.tool = Tool::Move; + h.state_mut().run("select.rect", json!({"x": 7, "y": 8, "width": 3, "height": 4})).unwrap(); + h.state_mut().run("select.float", json!({"dx": 5, "dy": 0})).unwrap(); + h.run_steps(2); + let grab = ViewXform::active(h.state()).unwrap().to_screen(13.0, 10.0); + h.event(egui::Event::ModifiersChanged(Modifiers::ALT)); + h.event(egui::Event::PointerMoved(grab)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: grab, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::ALT }); + h.run_steps(1); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(grab + vec2(0.0, 10.0 * k as f32))); + h.run_steps(1); + } + tab(h, "dest-doc") +} + +fn release_at(h: &mut Harness<'_, PhotocraftApp>, p: Pos2) { + h.event(egui::Event::ModifiersChanged(Modifiers::NONE)); + h.event(egui::Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn an_edit_during_an_option_drag_of_a_float_is_not_taken_back_by_a_tab_hover() { + let (mut h, paint) = harness(); + let over_tab = option_drag_of_a_moved_float(&mut h); + // An edit lands while the button is still held (as an opacity key would). + h.state_mut().run("layer.setProps", json!({"layer": paint.0, "opacity": 0.3})).unwrap(); + h.run_steps(1); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(over_tab + vec2(0.0, k as f32))); + h.run_steps(1); + } + let opacity = |h: &Harness<'_, PhotocraftApp>| h.state().session.documents()[0].doc.layer(paint).unwrap().opacity; + assert!((opacity(&h) - 0.3).abs() < 1e-6, "the hover took the opacity edit back"); + release_at(&mut h, over_tab); + assert!((opacity(&h) - 0.3).abs() < 1e-6, "the release took the opacity edit back"); + assert_eq!(h.state().session.documents()[1].doc.layers.len(), 1, "nothing is copied over a later edit"); +} + +#[test] +fn a_tab_hover_that_cannot_take_the_option_drag_back_keeps_the_move_going() { + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let (mut h, _) = harness(); + let over_tab = option_drag_of_a_moved_float(&mut h); + // A background job locks the source: the rollback can't run. + let gate = Arc::new(AtomicBool::new(false)); + let open = gate.clone(); + h.state_mut() + .session + .start_job( + "test.slow", + json!({}), + "Slow", + true, + move |ctx| { + while !open.load(Ordering::Relaxed) { + ctx.check()?; + std::thread::sleep(std::time::Duration::from_millis(1)); + } + Ok(()) + }, + |_, ()| Ok(json!({})), + ) + .unwrap(); + assert!(h.state().session.active_job().is_some()); + for k in 1..=4 { + h.event(egui::Event::PointerMoved(over_tab + vec2(0.0, k as f32))); + h.run_steps(1); + } + // Still the same ⌥-drag of the copy, its modifier state intact. + assert_eq!(h.state().session.active().unwrap().doc.name, "source-doc"); + assert!(h.state().drag.is_some()); + assert!(crate::move_mods::copying(h.state(), false), "the drag's move state was cleared"); + gate.store(true, Ordering::Relaxed); + release_at(&mut h, over_tab); +} + #[test] fn a_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() { let (mut h, _) = harness();