From 386a6804220ddb971867467eca2c00b9cdec7a06 Mon Sep 17 00:00:00 2001 From: Nostella Date: Thu, 29 Oct 2026 14:31:26 +1300 Subject: [PATCH 1/6] Transfer selected pixels between tabs and tiled documents --- crates/engine/src/cmd/float_cmds.rs | 5 ++ crates/engine/src/cmd/layer_copy_cmds.rs | 28 +++++++- .../engine/src/cmd/layer_copy_cmds/tests.rs | 29 ++++++++ crates/ui-egui/src/canvas/drag.rs | 4 ++ crates/ui-egui/src/canvas/tabs.rs | 1 + crates/ui-egui/src/canvas/tool_input.rs | 2 + crates/ui-egui/src/panels/layer_transfer.rs | 67 +++++++++++++++---- .../src/panels/layer_transfer/tests.rs | 62 +++++++++++++++++ 8 files changed, 184 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 0700fb30c8d..7d7e93acc30 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -44,6 +44,11 @@ pub struct CutParts { } impl CutParts { + /// Selected pixels in source-document coordinates, including soft coverage. + pub(crate) fn selected_pixels(&self) -> &Surface { + &self.piece + } + /// 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 diff --git a/crates/engine/src/cmd/layer_copy_cmds.rs b/crates/engine/src/cmd/layer_copy_cmds.rs index 0c12d5a21fb..0394aceb9d4 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,22 @@ 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) => 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())); + 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 +423,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..722cff64166 100644 --- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs @@ -28,6 +28,35 @@ 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(); + 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_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_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 53eb81c592c..a7675cc2623 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -30,6 +30,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]>, @@ -58,6 +61,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 7389104f6c1..d24262823c6 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 08aff7dd70f..60227214725 100644 --- a/crates/ui-egui/src/canvas/tool_input.rs +++ b/crates/ui-egui/src/canvas/tool_input.rs @@ -287,9 +287,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/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index 7534d63fbc1..136eda33d90 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,8 @@ 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 }; + let selection_only = grab.is_some() && 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 +62,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 +80,34 @@ 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; } // The Move drag ends without moving anything; an ⌥-drag's copy is taken back. - app.drag = None; + let previous = app.drag.take().and_then(|d| d.transfer_floating_before); app.move_preview = None; crate::move_mods::abandon(app); + // A fresh selection drag is only a floating preview. Returning that + // piece preserves source pixels/history when the drag becomes a copy. + if let Some(st) = app.session.active_mut() { + st.floating = previous; + } 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; @@ -110,6 +123,10 @@ pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) { /// layers there; while the button is held a ghost follows the pointer. pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(t) = get(ctx) else { return }; + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + clear(ctx); + return; + } let (pos, released, down) = ctx.input(|i| (i.pointer.latest_pos(), i.pointer.any_released(), i.pointer.primary_down())); let target = pos.and_then(|p| target_at(app, p)); if !released { @@ -131,6 +148,20 @@ pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) { clear(ctx); } +/// 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; + } + 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` that isn't the source, and the document point there /// (`None` in a tile: tiles keep their own views). fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; 2]>)> { @@ -138,7 +169,18 @@ fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; 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 xf = crate::canvas::ViewXform { + rect: crate::rulers::content_rect(app, r.shrink(1.0)), + zoom: view.zoom / app.ppp, + center: view.center, + flip: app.ui.view.flip_horizontal, + rotation: view.rotation, + aspect: app.ui.view.display_aspect(), + }; + 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 +208,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..9671534db9a 100644 --- a/crates/ui-egui/src/panels/layer_transfer/tests.rs +++ b/crates/ui-egui/src/panels/layer_transfer/tests.rs @@ -72,6 +72,68 @@ 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_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() { let (mut h, _) = harness(); From f2557d3149b7778fe84a2d4f18f61bc6a4b396ff Mon Sep 17 00:00:00 2001 From: Nostella Date: Thu, 29 Oct 2026 14:33:20 +1300 Subject: [PATCH 2/6] Credit tomgardenart as Nostella --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index a2ead57598d..3708238a0bd 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -315,3 +315,7 @@ sync_github_name = false [people.davidvhk] real_name = "David Vanhoucke" sync_github_name = true + +[people.tomgardenart] +display_name = "Nostella" +sync_github_name = false From b43a251279281adb2be78cbb51d365ff023a7dab Mon Sep 17 00:00:00 2001 From: tomgardenart Date: Sat, 7 Nov 2026 13:42:01 +1300 Subject: [PATCH 3/6] Clarify and test tiled layer transfers --- crates/ui-egui/src/panels/layer_transfer.rs | 12 ++++----- .../src/panels/layer_transfer/tests.rs | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index 136eda33d90..c0cf12ac71b 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -49,7 +49,9 @@ 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 selection_only = grab.is_some() && st.doc.selection.is_some(); + // 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 @@ -123,10 +125,6 @@ pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) { /// layers there; while the button is held a ghost follows the pointer. pub fn finish(app: &mut PhotocraftApp, ctx: &egui::Context) { let Some(t) = get(ctx) else { return }; - if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - clear(ctx); - return; - } let (pos, released, down) = ctx.input(|i| (i.pointer.latest_pos(), i.pointer.any_released(), i.pointer.primary_down())); let target = pos.and_then(|p| target_at(app, p)); if !released { @@ -162,8 +160,8 @@ pub(crate) fn prepare(app: &mut PhotocraftApp, ctx: &egui::Context) { } } -/// 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). +/// 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; diff --git a/crates/ui-egui/src/panels/layer_transfer/tests.rs b/crates/ui-egui/src/panels/layer_transfer/tests.rs index 9671534db9a..5670c370134 100644 --- a/crates/ui-egui/src/panels/layer_transfer/tests.rs +++ b/crates/ui-egui/src/panels/layer_transfer/tests.rs @@ -134,6 +134,32 @@ fn a_selection_can_be_dragged_directly_between_tiled_canvases() { 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_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() { let (mut h, _) = harness(); From faac50ea4656755b7d13e4b0c5a04e2264f1432a Mon Sep 17 00:00:00 2001 From: tomgardenart Date: Sat, 7 Nov 2026 20:26:55 +1300 Subject: [PATCH 4/6] fix: preserve selection transfer state --- crates/engine/src/cmd/float_cmds.rs | 7 +++- crates/engine/src/cmd/layer_copy_cmds.rs | 2 ++ .../engine/src/cmd/layer_copy_cmds/tests.rs | 18 ++++++++++ crates/ui-egui/src/canvas/view_xform.rs | 2 +- crates/ui-egui/src/panels/layer_transfer.rs | 24 ++++++++++--- .../src/panels/layer_transfer/tests.rs | 34 +++++++++++++++++++ 6 files changed, 80 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/float_cmds.rs b/crates/engine/src/cmd/float_cmds.rs index 7d7e93acc30..1406f59ba8d 100644 --- a/crates/engine/src/cmd/float_cmds.rs +++ b/crates/engine/src/cmd/float_cmds.rs @@ -49,6 +49,11 @@ impl CutParts { &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 @@ -345,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 0394aceb9d4..f8a4f06276d 100644 --- a/crates/engine/src/cmd/layer_copy_cmds.rs +++ b/crates/engine/src/cmd/layer_copy_cmds.rs @@ -261,6 +261,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { let src_layer = sdoc.layer(*id).ok_or(EngineError::NoLayer(*id))?; 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)?), }; @@ -268,6 +269,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result { 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); } diff --git a/crates/engine/src/cmd/layer_copy_cmds/tests.rs b/crates/engine/src/cmd/layer_copy_cmds/tests.rs index 722cff64166..ba97625696e 100644 --- a/crates/engine/src/cmd/layer_copy_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_copy_cmds/tests.rs @@ -33,6 +33,7 @@ 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(); @@ -40,6 +41,7 @@ fn selection_only_copy_preserves_source_and_soft_coverage_across_documents() { 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()); @@ -48,6 +50,22 @@ fn selection_only_copy_preserves_source_and_soft_coverage_across_documents() { } } +#[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); 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 c0cf12ac71b..33c2c639687 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -90,11 +90,17 @@ fn arm_move_transfer(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) return false; } // The Move drag ends without moving anything; an ⌥-drag's copy is taken back. - let previous = app.drag.take().and_then(|d| d.transfer_floating_before); + let Some(drag) = app.drag.take() else { return false }; + let previous = drag.transfer_floating_before.clone(); app.move_preview = None; crate::move_mods::abandon(app); - // A fresh selection drag is only a floating preview. Returning that - // piece preserves source pixels/history when the drag becomes a copy. + // 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)) && !app.session.rollback() { + app.drag = Some(drag); + return false; + } if let Some(st) = app.session.active_mut() { st.floating = previous; } @@ -152,6 +158,12 @@ 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) { @@ -169,14 +181,16 @@ fn target_at(app: &PhotocraftApp, p: egui::Pos2) -> Option<(usize, Option<[f64; match crate::canvas::arranged_cells(app, app.last_canvas_rect) { 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 / app.ppp, + 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)))), diff --git a/crates/ui-egui/src/panels/layer_transfer/tests.rs b/crates/ui-egui/src/panels/layer_transfer/tests.rs index 5670c370134..c780c52e425 100644 --- a/crates/ui-egui/src/panels/layer_transfer/tests.rs +++ b/crates/ui-egui/src/panels/layer_transfer/tests.rs @@ -160,6 +160,40 @@ fn a_plain_layer_can_be_dragged_directly_between_tiled_canvases() { 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 a_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() { let (mut h, _) = harness(); From 1f6e1953dd31e2150ada270ddc0b5fecc2c512fb Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 8 Nov 2026 16:34:17 +0100 Subject: [PATCH 5/6] Keep a moved float when an Option-drag of it becomes a tab transfer (#10059) Arming a cross-document transfer from an Option-drag of an existing float rolls back the drop-and-copy, which raises the document revision. The float was restored with its old revision, so floating() treated it as stale and the earlier move was lost. Restore it over the current revision, with a regression test. Co-Authored-By: tomgardenart Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/layer_transfer.rs | 4 ++- .../src/panels/layer_transfer/tests.rs | 32 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index 33c2c639687..75d3afd29ed 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -101,8 +101,10 @@ fn arm_move_transfer(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) app.drag = Some(drag); return false; } + // 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; + 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)) { diff --git a/crates/ui-egui/src/panels/layer_transfer/tests.rs b/crates/ui-egui/src/panels/layer_transfer/tests.rs index c780c52e425..76d59963be7 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 { @@ -194,6 +200,30 @@ fn a_tiled_selection_drag_that_returns_to_its_source_still_moves_there() { 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)); +} + #[test] fn a_layers_panel_drag_onto_a_tab_drops_the_layer_where_it_is_released() { let (mut h, _) = harness(); From e72d7d7266f1c5a637fd6fb01fb82d229fc60f64 Mon Sep 17 00:00:00 2001 From: David Mann Date: Mon, 9 Nov 2026 20:03:13 +0100 Subject: [PATCH 6/6] Keep a tab hover from taking back the wrong step of an Option-drag (#10059) Arming a cross-document transfer from an Option-drag of a moved float rolled back the latest history step. An edit made during the drag (an opacity key) was that step, so it was lost and the old float came back over the changed document. The hover now leaves the drag a plain move when the drag's own float is no longer current. When the rollback cannot run (a job locks the document), the drag's modifier state is no longer cleared before that check, so it carries on with its Shift lock and offset. Co-Authored-By: tomgardenart Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/layer_transfer.rs | 25 ++++-- .../src/panels/layer_transfer/tests.rs | 83 +++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/layer_transfer.rs b/crates/ui-egui/src/panels/layer_transfer.rs index 75d3afd29ed..23b282fc369 100644 --- a/crates/ui-egui/src/panels/layer_transfer.rs +++ b/crates/ui-egui/src/panels/layer_transfer.rs @@ -89,18 +89,27 @@ fn arm_move_transfer(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) if app.session.active_index() == Some(index) { return false; } - // The Move drag ends without moving anything; an ⌥-drag's copy is taken back. - let Some(drag) = app.drag.take() else { return false }; - let previous = drag.transfer_floating_before.clone(); - app.move_preview = None; - crate::move_mods::abandon(app); + 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)) && !app.session.rollback() { - app.drag = Some(drag); - return false; + 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() { diff --git a/crates/ui-egui/src/panels/layer_transfer/tests.rs b/crates/ui-egui/src/panels/layer_transfer/tests.rs index 76d59963be7..c088b48940e 100644 --- a/crates/ui-egui/src/panels/layer_transfer/tests.rs +++ b/crates/ui-egui/src/panels/layer_transfer/tests.rs @@ -224,6 +224,89 @@ fn an_option_drag_of_a_moved_float_onto_a_tab_keeps_the_source_float() { 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();