Merge pull request #10059 from tomgardenart/fix/selected-pixel-transfer-nostella

Drag selected pixels between document tabs and tiled canvases
This commit is contained in:
David Mann authored and GitHub committed 2026-11-10 01:03:25 +01:00
commit 625ea98a37
10 files changed
+413 -18

No files matched your search

+4
View File
@@ -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
+11 -1
View File
@@ -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<Value> {
/// 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))
}
+28 -2
View File
@@ -221,6 +221,7 @@ fn copy_to_document(s: &mut Session, p: &Value) -> Result<Value> {
}
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<LayerId> = 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<Value> {
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<Value> {
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<CommandSpec> {
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,
@@ -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()
}
+4
View File
@@ -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<photocraft_engine::float_cmds::Floating>,
/// 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,
+1
View File
@@ -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.
+2
View File
@@ -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;
+1 -1
View File
@@ -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) }
+79 -13
View File
@@ -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<egui
/// The pointer is over document tab `index` while layers are dragged: a Move drag becomes a
/// transfer (the source layers stay where they were), the tab's document is shown, and a release
/// right here drops the layers into it.
pub fn over_tab(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) {
fn arm_move_transfer(app: &mut PhotocraftApp, ctx: &egui::Context, index: usize) -> 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()]),
@@ -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();