fix(ui): paste delivered images instead of guessing from key releases

The pinned integration consumes native clipboard shortcut keys and delivers PasteImage pixels. Handle those payloads in order, keep text cache invalidation, and use the delivered image even if the OS clipboard changes before processing. Preserve alpha and validate dimensions and byte counts before allocation.

Remove release timestamps so Windows clipboard history cannot reuse a completed paste gesture. Keep explicit shortcut fallback for control-channel and browser internal-image paste, with modal, focus and permission guards unchanged. Tests cover both release orderings, history after prior paste, mixed batches and duplicate prevention.

Co-Authored-By: Codex GPT-6.1 <noreply@openai.com>
This commit is contained in:
FredApps
2026-11-01 16:30:06 +01:00
co-authored by Codex GPT-6.1
parent b7d04009a2
commit a3840e1eda
7 changed files with 301 additions and 134 deletions
-2
View File
@@ -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,
+216 -29
View File
@@ -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<egui::ColorImage>),
}
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<ClipboardPaste>, 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<egui::Event>) -> 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());
}
+1 -2
View File
@@ -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);
}
}
-3
View File
@@ -554,9 +554,6 @@ pub struct PdfCraftApp {
pub system_clipboard: bool,
/// What ⌘V reads instead of the system clipboard (tests).
pub clipboard_override: Option<create_ui::Clip>,
/// V is down as a plain key press (see [`Self::paste_key_released`]).
v_pressed: bool,
last_command_time: Option<f64>,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
/// Duplicate Field: which field and onto which pages.
+51 -80
View File
@@ -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<crate::create_ui::Clip>,
system_authoritative: bool,
clipboard: Option<crate::create_ui::Clip>,
) -> 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<u8>)> = match self.clipboard_override.clone() {
let image_data: Option<(String, Vec<u8>)> = 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<egui::Event>) -> 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));
+9 -7
View File
@@ -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");
}
+24 -11
View File
@@ -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.