diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs index 2784f29eef..b44d09b5c3 100644 --- a/crates/ui-egui/src/document_drag.rs +++ b/crates/ui-egui/src/document_drag.rs @@ -1,8 +1,10 @@ //! A selection dragged across document tabs remains editable and leaves its source intact. +use std::sync::Arc; + use egui::{Context, Id, Response}; use serde_json::json; -use vectorcraft_doc::NodeId; +use vectorcraft_doc::{Document, NodeId}; use vectorcraft_geom::Point; use crate::{VectorcraftApp, canvas, widgets}; @@ -19,7 +21,11 @@ struct Origin { struct Artwork { origin: Origin, ids: Vec, - revision: u64, + /// The source document when the drag left it. The drag ends when the source's content differs + /// from it ([`vectorcraft_engine::DocState::same_content`]). A redraw that raises its revision + /// (another document's assigned color profiles brought to the front) and a change that is not + /// an edit (its type measured again) keep it. + source: Arc, target: u64, } @@ -75,7 +81,7 @@ pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) { } }); if let Some(art) = egui::DragAndDrop::payload::(ctx) { - let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.revision == art.revision) + let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.same_content(&art.source)) && app.session.active().is_some_and(|d| d.uid == art.target); let cancel = ctx.input(|i| { !i.focused @@ -143,13 +149,14 @@ pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, win: crate::d return true; } // Alt-drag previews use temporary duplicate ids; only the restored originals travel. - let ids = app.session.active().map(|d| d.selection.objects.clone()).unwrap_or_default(); + let Some((ids, source)) = app.session.active().map(|d| (d.selection.objects.clone(), d.doc.clone())) else { + return true; + }; if ids.is_empty() { return true; } canvas::end_document_drag(ctx); - let revision = app.session.active().map(|d| d.revision).unwrap_or_default(); - egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, revision, target: uid }); + egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, source, target: uid }); } if transfer && let Some(art) = egui::DragAndDrop::payload::(ctx) { let mut art = (*art).clone(); diff --git a/crates/ui-egui/src/tests/document_drag.rs b/crates/ui-egui/src/tests/document_drag.rs index f01314a667..cefe441020 100644 --- a/crates/ui-egui/src/tests/document_drag.rs +++ b/crates/ui-egui/src/tests/document_drag.rs @@ -291,6 +291,54 @@ fn hovering_back_to_source_resumes_move_instead_of_copying() { assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}"); } +/// Back in the source tab, the move resumes even when coming to the front measured the source's +/// type again in its own composite fonts, which replaces the document without editing it. +#[test] +fn hovering_back_to_a_source_whose_type_is_measured_again_resumes_move() { + let font = |roman: u32| { + json!({ + "name": "CF Tab Drag", + "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "JetBrains Mono", "size": roman}}, + }) + }; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap(); + app.run("type.compositeFonts.set", json!({"fonts": [font(100)]})).unwrap(); + app.run("text.create", json!({"x": 200, "y": 250, "text": "Ab12", "size": 20, "font": "CF Tab Drag"})).unwrap(); + let id = vectorcraft_doc::NodeId(app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap()["id"].as_u64().unwrap()); + app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap(); + app.run("type.compositeFonts.set", json!({"fonts": [font(50)]})).unwrap(); + app.session.set_active(0); + let undo = app.session.active().unwrap().history.undo.len(); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, 0.0, vec![]); + let tabs = frame(&mut app, &ctx, 0.01, vec![]); + let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0)); + frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]); + frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]); + frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]); + frame(&mut app, &ctx, 0.25, vec![]); + frame(&mut app, &ctx, 0.8, vec![]); + assert_eq!(app.session.active_index(), Some(1)); + let left = app.session.documents()[0].doc.clone(); + frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(tabs[0].center())]); + frame(&mut app, &ctx, 1.4, vec![]); + assert_eq!(app.session.active_index(), Some(0)); + assert!(!std::sync::Arc::ptr_eq(&app.session.documents()[0].doc, &left), "the source's type is measured again"); + let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0)); + frame(&mut app, &ctx, 1.5, vec![Event::PointerMoved(drop)]); + frame(&mut app, &ctx, 1.6, vec![button(drop, false)]); + let st = app.session.active().unwrap(); + assert_eq!(st.selection.objects, vec![id]); + assert_eq!(st.history.undo.len(), undo + 1); + let bounds = st.doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!((bounds.x0 - 150.0).abs() < 0.001 && (bounds.y0 - 150.0).abs() < 0.001, "{bounds:?}"); +} + /// Verify Alt-drag transfers restored originals across tabs without retaining preview duplicates. #[test] fn alt_drag_can_continue_across_multiple_tabs_without_copying_the_source_preview() { @@ -380,6 +428,58 @@ fn source_edits_target_closure_switches_and_focus_loss_cancel_stale_transfers() } } +/// Artwork held over the tab of a document with other assigned color profiles switches to it and +/// drops there: bringing those profiles to the front redraws the source without editing it. +#[test] +fn a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork() { + const CHILD: &str = "VECTORCRAFT_TEST_DRAG_ASSIGNED_PROFILES"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: change them in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "tests::document_drag::a_drop_on_a_document_with_other_assigned_profiles_copies_the_artwork", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated drag test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300,"name":"Source"})).unwrap(); + app.run("shape.rectangle", json!({"x":50,"y":50,"width":100,"height":100})).unwrap(); + app.run("file.new", json!({"width":400,"height":300,"name":"Target"})).unwrap(); + app.run("edit.assignProfile", json!({"cmyk": vectorcraft_color::cms::DEVICE_CMYK})).unwrap(); + app.session.set_active(0); + let source = app.session.active().unwrap().doc.clone(); + app.select_tool("selection"); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, 0.0, vec![]); + let tabs = frame(&mut app, &ctx, 0.01, vec![]); + let grab = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(75.0, 80.0)); + frame(&mut app, &ctx, 0.05, vec![Event::PointerMoved(grab), button(grab, true)]); + frame(&mut app, &ctx, 0.1, vec![Event::PointerMoved(grab + egui::vec2(20.0, 10.0))]); + assert!(app.session.in_interaction()); + frame(&mut app, &ctx, 0.2, vec![Event::PointerMoved(tabs[1].center())]); + frame(&mut app, &ctx, 0.25, vec![]); + frame(&mut app, &ctx, 0.8, vec![]); + assert_eq!(app.session.active_index(), Some(1)); + let undo = app.session.active().unwrap().history.undo.len(); + let drop = canvas::Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(175.0, 180.0)); + frame(&mut app, &ctx, 0.9, vec![Event::PointerMoved(drop)]); + frame(&mut app, &ctx, 1.0, vec![button(drop, false)]); + let placed = app.session.active().unwrap().selection.objects.len(); + let steps = app.session.active().unwrap().history.undo.len(); + assert_eq!((placed, steps), (1, undo + 1), "the drop places the artwork"); + assert_eq!(app.session.documents()[0].doc, source); +} + /// Verify tab-strip relayout under a stationary pointer cannot trigger another document switch. #[test] fn scrolling_tabs_under_a_stationary_drag_does_not_cascade_to_other_documents() {