diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 2f7fb15ce6..abfecba94f 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -230,8 +230,6 @@ impl PdfCraftApp { last_copied_file: None, system_clipboard: true, clipboard_override: None, - v_pressed: false, - last_command_time: None, attach_override: None, duplicate_draft: None, page_props_draft: None, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index 1e1d52c27d..9319368d29 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str { tl!("The document's security settings don't allow printing") } +enum ClipboardPaste { + Text, + Image(std::sync::Arc), +} + impl PdfCraftApp { /// Whether text and images may be copied out of document `id`; says why not when they may /// not. Reading aloud and accessibility tools don't ask (assistive technology may read @@ -23,30 +28,29 @@ impl PdfCraftApp { } /// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus). - pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context, pasted: bool) { + pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) { use egui::{Key, KeyboardShortcut, Modifiers}; - let paste_event_text = ctx.input(|input| { - input.events.iter().find_map(|e| match e { - egui::Event::Paste(text) if !text.is_empty() => Some(text.clone()), - _ => None, - }) + let pastes: Vec<_> = ctx.input(|input| { + input + .events + .iter() + .filter_map(|event| match event { + egui::Event::Paste(_) => Some(ClipboardPaste::Text), + egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())), + _ => None, + }) + .collect() }); - if paste_event_text.is_some() { - // A text field may own the paste; the canvas cache must still observe the new contents. - self.last_copied_image = None; - self.last_copied_file = None; - } let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing(); if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) { + self.receive_pastes(i, pastes, false); return; } let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy))) || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C))); let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut))) || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X))); - let paste = pasted - || ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Paste(_)))) - || ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); + let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V))); if copy && !self.copy_selected_text(i, ctx) @@ -63,19 +67,18 @@ impl PdfCraftApp { { self.cut_page_image(sel.page, sel.index); } + if !pastes.is_empty() { + self.receive_pastes(i, pastes, true); + return; + } if paste { - // A release is only a fallback for image shortcuts whose press the platform swallowed. - // Text paste already reached its owner; an empty image clipboard needs no second notice. - if pasted && paste_event_text.is_none() && !self.can_paste_image() { - return; - } let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. })); #[cfg(not(target_arch = "wasm32"))] let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false }; #[cfg(target_arch = "wasm32")] let system_has_text = false; - if !has_override_image && (paste_event_text.is_some() || system_has_text) { + if !has_override_image && system_has_text { self.last_copied_image = None; self.last_copied_file = None; return; @@ -86,6 +89,23 @@ impl PdfCraftApp { } } + fn receive_pastes(&mut self, view: usize, pastes: Vec, images_allowed: bool) { + let page = self.views.get(view).map_or(0, |view| view.current); + for paste in pastes { + match paste { + ClipboardPaste::Text => { + // Text fields may own the paste; the canvas cache still observes its contents. + self.last_copied_image = None; + self.last_copied_file = None; + } + ClipboardPaste::Image(image) if images_allowed => { + self.paste_received_image(page, &image); + } + ClipboardPaste::Image(_) => {} + } + } + } + /// Shared permission gate for copying selected page text from menus and keyboard events. /// After Select All of the document (#2639), pages whose text wasn't read for display are read /// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done. @@ -137,6 +157,147 @@ impl PdfCraftApp { #[cfg(test)] mod review_tests { use super::*; + fn delivered() -> egui::Event { + egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN))) + } + + fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers } + } + + fn image_app() -> PdfCraftApp { + let mut app = PdfCraftApp::new(); + app.system_clipboard = false; + app.create_blank(); + app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] }); + app + } + + fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec) -> usize { + ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); + app.session.get(app.views[0].id).unwrap().page_images(0).len() + } + + #[test] + fn delivered_image_pastes_for_either_modifier_release_order() { + for command_first in [true, false] { + let mut app = image_app(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]); + let releases = if command_first { + vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)] + } else { + vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)] + }; + assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste"); + assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste"); + assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1); + } + } + + #[test] + fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() { + for explicit_press in [false, true] { + let mut app = image_app(); + let ctx = egui::Context::default(); + let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()]; + if explicit_press { + first.push(key(true, egui::Modifiers::COMMAND)); + } + first.push(key(false, egui::Modifiers::COMMAND)); + assert_eq!(frame(&mut app, &ctx, 1.0, first), 1); + // The integration drops Windows-key identity; Win+V can leave only this release. + assert_eq!( + frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), + 1, + "opening history must not paste" + ); + assert_eq!( + frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), + 2, + "choosing an item produces exactly one intentional paste" + ); + } + } + + #[test] + fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() { + let mut app = image_app(); + let ctx = egui::Context::default(); + let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]); + assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0); + assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0); + assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0); + } + + #[test] + fn delivered_image_is_authoritative_and_not_duplicated_by_keys() { + let mut app = image_app(); + app.system_clipboard = true; + app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into())); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1); + assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]); + } + + #[test] + fn text_then_image_batch_pastes_image_and_keeps_its_cache() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1); + assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]); + } + + #[test] + fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1); + assert!(app.last_copied_image.is_none()); + assert!(app.last_copied_file.is_none()); + } + + #[test] + fn separate_delivered_images_in_one_batch_each_paste_once() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!( + frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), + 2 + ); + } + + #[test] + fn explicit_control_shortcut_still_pastes_once_without_an_image_event() { + let mut app = image_app(); + let ctx = egui::Context::default(); + assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1); + assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1); + } + + #[test] + fn delivered_image_obeys_focus_modal_and_permission_guards() { + let ctx = egui::Context::default(); + let mut app = image_app(); + app.palette_open = true; + assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0); + app.palette_open = false; + app.views[0].organize = true; + assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0); + app.views[0].organize = false; + app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection { + permissions_password: Some("owner".into()), + ..Default::default() + })); + let bytes = app.session.save_bytes(app.views[0].id).unwrap(); + let mut restricted = PdfCraftApp::new(); + restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap(); + assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification()); + assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0); + } + #[test] fn review_an_image_less_release_is_silent_but_explicit_paste_explains() { let mut app = PdfCraftApp::new(); @@ -144,7 +305,20 @@ mod review_tests { app.create_blank(); app.toast = None; let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput::default(), |ui| app.clipboard_keys(0, ui.ctx(), true)).drop_without_applying_deltas(); + ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::V, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.clipboard_keys(0, ui.ctx()), + ) + .drop_without_applying_deltas(); assert!(app.toast.is_none()); assert!(!app.paste_image(0, None)); assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image"); @@ -158,17 +332,30 @@ mod review_tests { app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255])); app.palette_open = true; // A text field owns the first event. let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| { - app.clipboard_keys(0, ui.ctx(), false) - }) - .drop_without_applying_deltas(); + ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); assert!(app.last_copied_image.is_none()); app.palette_open = false; app.toast = None; - ctx.run_ui(egui::RawInput::default(), |ui| app.clipboard_keys(0, ui.ctx(), true)).drop_without_applying_deltas(); + ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::V, + physical_key: None, + pressed: false, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| app.clipboard_keys(0, ui.ctx()), + ) + .drop_without_applying_deltas(); assert!(app.toast.is_none(), "the text gesture's key release must be silent"); - app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![0, 255, 0, 255] }); - ctx.run_ui(egui::RawInput::default(), |ui| app.clipboard_keys(0, ui.ctx(), true)).drop_without_applying_deltas(); + app.clipboard_override = None; + let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN))); + ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) + .drop_without_applying_deltas(); let id = app.active_ids().unwrap().1; assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works"); } @@ -180,7 +367,7 @@ mod review_tests { app.create_blank(); app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0)); let ctx = egui::Context::default(); - ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx(), false)) + ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx())) .drop_without_applying_deltas(); assert!(app.views[0].image_selection.is_some()); } diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index da048dc36c..c014453806 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -3,7 +3,6 @@ use super::*; impl PdfCraftApp { pub(super) fn shortcuts(&mut self, ctx: &egui::Context) { use egui::Key; - let pasted = self.paste_key_released(ctx); // "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document // properties; Escape cancels it rather than clearing a selection), and nothing may run // underneath it. They are read here, before the canvas can consume them. @@ -78,7 +77,7 @@ impl PdfCraftApp { prepare::nudge_keys(ctx, &mut self.views[i], doc); } canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed); - self.clipboard_keys(i, ctx, pasted); + self.clipboard_keys(i, ctx); self.image_pick_keys(i, ctx); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 606862fec4..37e8e53165 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -554,9 +554,6 @@ pub struct PdfCraftApp { pub system_clipboard: bool, /// What ⌘V reads instead of the system clipboard (tests). pub clipboard_override: Option, - /// V is down as a plain key press (see [`Self::paste_key_released`]). - v_pressed: bool, - last_command_time: Option, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index b532d9b86d..4bc3068c86 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -1029,32 +1029,6 @@ impl crate::PdfCraftApp { } } - /// Whether ⌘V / Ctrl+V was let go this frame. egui-winit swallows that press (with a Paste - /// event only if the clipboard holds text), while a plain V press always comes through: a - /// release of V with no press of V since the last one is the shortcut, even when ⌘ or Ctrl - /// was let go first. Read every frame, dialogs or not, so a press is never missed. - pub(crate) fn paste_key_released(&mut self, ctx: &egui::Context) -> bool { - let mut released = false; - ctx.input(|inp| { - for e in &inp.events { - if let egui::Event::Key { key: egui::Key::V, pressed, modifiers, .. } = e { - if modifiers.command { - self.last_command_time = Some(inp.time); - } else if *pressed { - self.last_command_time = None; - } - let command_recent = self.last_command_time.is_some_and(|t| inp.time >= t && inp.time - t < 0.35); - let command_active = modifiers.command || command_recent; - if !*pressed && !self.v_pressed && command_active { - released = true; - } - self.v_pressed = *pressed; - } - } - }); - released - } - /// Whether there is an image in the in-memory clipboard or system clipboard ready to paste. pub fn can_paste_image(&self) -> bool { if let Some(crate::create_ui::Clip::Image { .. }) = self.clipboard_override.as_ref() { @@ -1072,6 +1046,26 @@ impl crate::PdfCraftApp { self.last_copied_image.is_some() || self.last_copied_file.is_some() } + /// Paste the integration's delivered pixels without rereading a clipboard that may have changed. + pub(crate) fn paste_received_image(&mut self, page: usize, image: &egui::ColorImage) -> bool { + let [width, height] = image.size; + let pixels = width.checked_mul(height); + let bytes = pixels.and_then(|n| n.checked_mul(4)); + if width == 0 + || height == 0 + || width > crate::create_ui::MAX_IMAGE_SIDE + || height > crate::create_ui::MAX_IMAGE_SIDE + || pixels != Some(image.pixels.len()) + || bytes.is_none_or(|n| n > crate::create_ui::MAX_IMAGE_BYTES) + { + self.notify_error("the clipboard image is empty, malformed or exceeds size limits"); + return false; + } + let rgba = image.pixels.iter().flat_map(egui::Color32::to_srgba_unmultiplied).collect(); + let delivered = crate::create_ui::Clip::Image { width, height, rgba }; + self.paste_image_with_system(page, None, None, None, false, Some(delivered)) + } + /// Paste an image from the clipboard onto `page`. pub fn paste_image(&mut self, page: usize, rect: Option<[f64; 4]>) -> bool { self.paste_image_internal(page, rect, None) @@ -1087,7 +1081,14 @@ impl crate::PdfCraftApp { let system = if self.clipboard_override.is_none() && self.system_clipboard { crate::os_clipboard::for_image_paste() } else { None }; #[cfg(target_arch = "wasm32")] let system = None; - self.paste_image_with_system(page, rect, at, system, cfg!(not(target_arch = "wasm32")) && self.system_clipboard) + self.paste_image_with_system( + page, + rect, + at, + system, + cfg!(not(target_arch = "wasm32")) && self.system_clipboard, + self.clipboard_override.clone(), + ) } fn paste_image_with_system( @@ -1097,6 +1098,7 @@ impl crate::PdfCraftApp { at: Option<[f64; 2]>, system: Option, system_authoritative: bool, + clipboard: Option, ) -> bool { let Some((i, id)) = self.active_ids() else { return false; @@ -1108,7 +1110,7 @@ impl crate::PdfCraftApp { return false; } - let image_data: Option<(String, Vec)> = match self.clipboard_override.clone() { + let image_data: Option<(String, Vec)> = match clipboard { Some(crate::create_ui::Clip::Image { width, height, rgba }) => { let is_same = self .last_copied_image @@ -1308,35 +1310,28 @@ mod tests { use super::*; - fn review_v_release(app: &mut crate::PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec) -> bool { - let mut paste = false; - ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| { - paste = app.paste_key_released(ui.ctx()); - }) - .drop_without_applying_deltas(); - paste - } - - fn review_key(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { - egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + #[test] + fn delivered_pixels_are_bounded_before_cache_or_document_changes() { + let mut app = crate::PdfCraftApp::new(); + app.create_blank(); + app.last_copied_image = Some((1, 1, vec![0, 255, 0, 255])); + let before = app.last_copied_image.clone(); + for size in [[0, 1], [usize::MAX, 2], [crate::create_ui::MAX_IMAGE_SIDE + 1, 1], [2, 2]] { + let mut image = egui::ColorImage::filled([1, 1], egui::Color32::RED); + image.size = size; + assert!(!app.paste_received_image(0, &image)); + assert_eq!(app.last_copied_image, before); + } + assert!(app.session.get(app.views[0].id).unwrap().page_images(0).is_empty()); } #[test] - fn review_plain_v_after_another_shortcut_does_not_paste() { + fn delivered_pixels_keep_unpremultiplied_alpha() { let mut app = crate::PdfCraftApp::new(); - let ctx = egui::Context::default(); - review_v_release( - &mut app, - &ctx, - 1.0, - vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), review_key(egui::Key::C, true, egui::Modifiers::COMMAND)], - ); - assert!(!review_v_release( - &mut app, - &ctx, - 1.1, - vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), review_key(egui::Key::V, false, egui::Modifiers::NONE)] - )); + app.create_blank(); + let image = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[255, 0, 0, 128]); + assert!(app.paste_received_image(0, &image)); + assert_eq!(app.last_copied_image.unwrap().2, vec![255, 0, 0, 128]); } #[test] @@ -1345,7 +1340,7 @@ mod tests { app.create_blank(); app.system_clipboard = true; app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255])); - assert!(app.paste_image_with_system(0, None, None, None, false)); + assert!(app.paste_image_with_system(0, None, None, None, false, None)); let id = app.active_ids().unwrap().1; assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1); } @@ -1356,7 +1351,7 @@ mod tests { app.create_blank(); app.system_clipboard = true; app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255])); - assert!(!app.paste_image_with_system(0, None, None, None, true)); + assert!(!app.paste_image_with_system(0, None, None, None, true, None)); } #[test] @@ -1392,36 +1387,12 @@ mod tests { let id = app.active_ids().unwrap().1; app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255])); let undo = app.session.get(id).unwrap().can_undo().map(str::to_owned); - assert!(!app.paste_image_with_system(0, None, Some([20.0, 20.0]), Some(crate::create_ui::Clip::Text("new text".into())), true)); + assert!(!app.paste_image_with_system(0, None, Some([20.0, 20.0]), Some(crate::create_ui::Clip::Text("new text".into())), true, None)); assert_eq!(app.session.get(id).unwrap().can_undo().map(str::to_owned), undo); assert!(app.session.get(id).unwrap().page_images(0).is_empty()); assert!(app.last_copied_image.is_none()); } - #[test] - fn a_plain_v_release_long_after_command_is_not_a_paste() { - let mut app = crate::PdfCraftApp::new(); - let ctx = egui::Context::default(); - app.last_command_time = Some(1.0); - ctx.run_ui( - egui::RawInput { - time: Some(10.0), - events: vec![egui::Event::Key { - key: egui::Key::V, - physical_key: None, - pressed: false, - repeat: false, - modifiers: egui::Modifiers::NONE, - }], - ..Default::default() - }, - |ui| { - assert!(!app.paste_key_released(ui.ctx()), "elapsed time expires command even if no frames were drawn"); - }, - ) - .drop_without_applying_deltas(); - } - /// The image a flip button leaves behind, as the panel returns it. fn flipped(tooltip: &str) -> pdfcraft_engine::AddedImage { let img = pdfcraft_engine::AddedImage::new([0.0, 0.0, 100.0, 50.0], pdfcraft_cos::ObjRef::new(7, 0)); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 34579f3fe4..ccc3945aaf 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -289,9 +289,8 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { h.event(v(false, egui::Modifiers::NONE)); h.run_steps(3); assert_eq!(images(&h), 0, "a plain V doesn't paste"); - // A picture alone: egui-winit sends no Paste event and swallows the press, so only the - // release of V arrives. - h.event(v(false, egui::Modifiers::COMMAND)); + // The integration delivers image pixels and consumes the native shortcut keys. + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); let a = added(&h); assert_eq!(a.len(), 1); @@ -302,14 +301,17 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add image")); } - // Ctrl let go a moment before V: the release comes without the modifier, and still pastes. + // Modifier release order does not change delivery of the next image paste. + h.event(egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)); + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.event(v(false, egui::Modifiers::NONE)); h.run_steps(3); assert_eq!(images(&h), 2, "Ctrl released first"); // A text field with the keyboard keeps ⌘V for itself. assert!(h.state_mut().execute("edit.find")); h.run_steps(3); - h.event(v(false, egui::Modifiers::COMMAND)); + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "the Find field has the keyboard"); h.key_press(egui::Key::Escape); @@ -318,7 +320,7 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { assert!(h.state_mut().execute("page.organize")); h.run_steps(4); assert!(h.state().views[0].organize); - h.event(v(false, egui::Modifiers::COMMAND)); + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "Organize Pages pastes pages"); h.state_mut().views[0].organize = false; @@ -327,7 +329,7 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() { assert!(h.state_mut().execute("form.prepare")); h.run_steps(3); assert!(h.state().is_preparing()); - h.event(v(false, egui::Modifiers::COMMAND)); + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([30, 20], egui::Color32::GRAY)))); h.run_steps(3); assert_eq!(images(&h), 2, "Prepare Form pastes fields"); } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index a0f822ee89..4e67e0056a 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7396,8 +7396,10 @@ fn image_copy_cut_paste_keyboard_and_context_menu() { h.run_steps(2); assert!(h.state().last_copied_image.is_some(), "image copied to clipboard buffer"); - // 3. Paste via keyboard shortcut (COMMAND+V / Event::Paste) - h.event(egui::Event::Paste("".into())); + // 3. The integration delivers the copied pixels for the keyboard paste. + let (width, height, rgba) = h.state().last_copied_image.clone().unwrap(); + let image = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &rgba); + h.event(egui::Event::PasteImage(std::sync::Arc::new(image))); h.run_steps(3); let after_paste = h.state().session.get(id).unwrap().page_images(0).len(); assert_eq!(after_paste, 2, "now has 2 images"); @@ -7506,11 +7508,11 @@ fn stale_clipboard_plain_text_does_not_paste_image() { assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page"); } -/// Issue 1: V key release with COMMAND delivers exactly one paste (not two). +/// Delivered image pixels paste once; accompanying key releases add no second image. #[test] -fn paste_key_release_delivers_single_image() { +fn delivered_image_with_key_releases_pastes_once() { let pdf = include_bytes!("../data/form.pdf").to_vec(); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.system_clipboard = false; @@ -7526,19 +7528,23 @@ fn paste_key_release_delivers_single_image() { h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 40, height: 20, rgba: [100u8, 200, 100, 255].repeat(40 * 20) }); - // Inject only a V release with COMMAND (no press event), exactly as egui-winit delivers + // The integration supplies the pixels and consumes native shortcut keys. Extra releases carry no paste authority. + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN)))); h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND }); h.run_steps(3); let count = h.state().session.get(id).unwrap().page_images(0).len(); - assert_eq!(count, initial_count + 1, "exactly one image must be pasted on V release, got {count}"); + assert_eq!(count, initial_count + 1, "exactly one image must be pasted for delivered pixels, got {count}"); + h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(2); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), initial_count + 1); } /// Win+V release without COMMAND must NOT paste an image. #[test] fn win_v_does_not_paste_on_open() { let pdf = include_bytes!("../data/form.pdf").to_vec(); - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut h = Harness::builder().with_step_dt(1.0 / 60.0).with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); app.system_clipboard = false; @@ -7554,20 +7560,27 @@ fn win_v_does_not_paste_on_open() { h.state_mut().clipboard_override = Some(pdfcraft_ui_egui::Clip::Image { width: 40, height: 20, rgba: [100u8, 200, 100, 255].repeat(40 * 20) }); + // Complete an intentional paste immediately before opening clipboard history. + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN)))); + h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND }); + h.run_steps(1); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), initial_count + 1); + // Simulate pressing Win+V (opening clipboard history flyout): // The OS intercepts V press, so PdfCraft only sees V release without COMMAND (Modifiers::NONE). h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }); h.run_steps(2); let count = h.state().session.get(id).unwrap().page_images(0).len(); - assert_eq!(count, initial_count, "Win+V release without command must NOT paste an image"); + assert_eq!(count, initial_count + 1, "opening Win+V after a completed paste must not paste again"); - // Then user selects the image in the Win+V flyout: Windows injects synthetic Ctrl+V (release with COMMAND) + // Choosing an item delivers another image, possibly alongside synthetic shortcut events. + h.event(egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([40, 20], egui::Color32::GREEN)))); h.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::COMMAND }); h.run_steps(3); let final_count = h.state().session.get(id).unwrap().page_images(0).len(); - assert_eq!(final_count, initial_count + 1, "Win+V paste must deliver exactly one image, not two"); + assert_eq!(final_count, initial_count + 2, "the two intentional deliveries must paste exactly two images"); } /// Normal Ctrl+V press followed by release delivers exactly one image, not two.