Files
pdfcraft/crates/ui-egui/src/app/clipboard.rs
T

612 lines
28 KiB
Rust

use super::*;
/// Why nothing can be copied out of the document: its security doesn't allow it (Table 22,
/// bit 5; #568).
pub(crate) fn copy_refused() -> &'static str {
tl!("The document's security settings don't allow copying text and images")
}
pub(crate) fn print_refused() -> &'static str {
tl!("The document's security settings don't allow printing")
}
enum ClipboardPaste {
Text(String),
Image(std::sync::Arc<egui::ColorImage>),
}
/// What objects copied in Edit a PDF put on the system clipboard for other applications, so ⌘V
/// can tell whether it still holds that copy or something copied since: their text, or else the
/// first picture's pixels (a hash of them), or nothing.
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) enum ObjectClipMark {
Text(String),
Picture(u64),
#[default]
None,
}
fn picture_hash(width: usize, height: usize, rgba: &[u8]) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(width, height, rgba).hash(&mut h);
h.finish()
}
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
/// anything, §7.6.4.2).
pub(crate) fn may_copy(&mut self, id: DocId) -> bool {
if self.session.get(id).is_none_or(pdfcraft_engine::Document::allows_copying) {
return true;
}
self.notify(copy_refused().to_string());
false
}
/// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field
/// has focus, or something is over the pages). With no text selected, ⌘C copies the selected
/// comments, each with its replies.
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
use egui::{Key, KeyboardShortcut, Modifiers};
let pastes: Vec<_> = ctx.input(|input| {
input
.events
.iter()
.filter_map(|event| match event {
egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())),
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
_ => None,
})
.collect()
});
let free = self.dialog.is_none() && !self.palette_open && !self.page_keys.over() && !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 = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
// Objects selected together in Edit a PDF take ⌘C and ⌘X.
let objects = (copy || cut) && self.quick_tool == QuickTool::EditText && self.views.get(i).is_some_and(|v| v.objects.count() > 0);
if objects {
self.copy_objects(i, ctx, cut);
}
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
if copy
&& !objects
&& (text_selected || !self.copy_selected_comments(i))
&& !self.copy_selected_text(i, ctx)
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
self.copy_page_image(sel.page, sel.index);
}
if cut
&& !objects
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
&& let Some(id) = self.views.get(i).map(|v| v.id)
&& self.may_copy(id)
{
self.cut_page_image(sel.page, sel.index);
}
if (paste || !pastes.is_empty()) && self.quick_tool == QuickTool::EditText && self.object_clipboard.is_some() && self.pastes_objects(&pastes)
{
self.paste_objects(i);
return;
}
if !pastes.is_empty() {
self.receive_pastes(i, pastes, true);
return;
}
if paste {
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 && system_has_text {
self.last_copied_image = None;
self.last_copied_file = None;
return;
}
let page = self.views.get(i).map_or(0, |v| v.current);
self.paste_image(page, None);
}
}
/// ⌘C / ⌘X in Edit a PDF with objects selected together: copy them for ⌘V in any open
/// document, and put their text (or else their first picture) on the system clipboard for
/// other applications. A cut then deletes them, as one undo step.
pub(crate) fn copy_objects(&mut self, i: usize, ctx: &egui::Context, cut: bool) -> bool {
let Some((id, Some((page, targets)))) = self.views.get(i).map(|v| (v.id, v.objects.selection())) else { return false };
if !self.may_copy(id) {
return false;
}
let Some(doc) = self.session.get(id) else { return false };
if cut && !doc.allows_modification() {
self.notify_error(tl!("The document's security settings don't allow this change"));
return false;
}
let clip = match doc.copy_objects(page, &targets) {
Ok(clip) => clip,
Err(e) => {
self.notify_error(e);
return false;
}
};
// A newer copy wins over a whole-document copy still reading its pages.
self.document_copy = None;
let text = clip.text();
let picture =
clip.objects().iter().find(|o| o.target.kind == pdfcraft_engine::ObjectKind::Image && o.content_type == "image").map(|o| o.target.index);
self.object_clip_mark = if !text.is_empty() {
ctx.copy_text(text.clone());
ObjectClipMark::Text(text)
} else if let Some(index) = picture.filter(|_| self.system_clipboard && cfg!(not(target_arch = "wasm32")))
&& self.copy_page_image(page, index)
&& let Some((w, h, rgba)) = &self.last_copied_image
{
ObjectClipMark::Picture(picture_hash(*w as usize, *h as usize, rgba))
} else {
ObjectClipMark::None
};
self.object_clipboard = Some(pdfcraft_engine::ObjectClipboard::new(clip));
if cut && let Some(delete) = pdfcraft_engine::Edit::delete_objects(page, targets) {
self.apply_edit(delete);
}
true
}
/// Whether ⌘V's `pastes` (what the system clipboard gave, if anything) are the objects
/// copied in Edit a PDF, rather than something copied since, here or in another application.
fn pastes_objects(&self, pastes: &[ClipboardPaste]) -> bool {
pastes.iter().all(|paste| match (paste, &self.object_clip_mark) {
(ClipboardPaste::Text(text), ObjectClipMark::Text(copied)) => text.replace("\r\n", "\n").trim_end() == copied.trim_end(),
(ClipboardPaste::Text(text), ObjectClipMark::None) => !pdfcraft_engine::is_xfdf_comments(text),
(ClipboardPaste::Image(image), ObjectClipMark::Picture(hash)) => {
let rgba: Vec<u8> = image.pixels.iter().flat_map(egui::Color32::to_srgba_unmultiplied).collect();
picture_hash(image.size[0], image.size[1], &rgba) == *hash
}
(ClipboardPaste::Image(_), ObjectClipMark::None) => true,
_ => false,
})
}
/// ⌘V in Edit a PDF: the copied objects onto the current page of tab `i`, as page content
/// that draws the same (one undo step), and selected.
pub(crate) fn paste_objects(&mut self, i: usize) -> bool {
let Some((id, page)) = self.views.get(i).map(|v| (v.id, v.current)) else { return false };
let (Some(doc), Some(clipboard)) = (self.session.get(id), self.object_clipboard.as_mut()) else { return false };
if !doc.allows_modification() {
self.notify_error(tl!("The document's security settings don't allow this change"));
return false;
}
let before = doc.editable_objects(page).unwrap_or_default();
let edit = match clipboard.paste(doc, page) {
Ok(edit) => edit,
Err(e) => {
self.notify_error(e);
return false;
}
};
if !self.apply_edit(edit) {
return false;
}
if let (Some(Ok(after)), Some(view)) = (self.session.get(id).map(|d| d.editable_objects(page)), self.views.get_mut(i)) {
view.objects.select_pasted(page, &before, after);
}
true
}
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
/// pictures and copied comments land on the page.
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
let page = self.views.get(view).map_or(0, |view| view.current);
for paste in pastes {
match paste {
ClipboardPaste::Text(text) => {
// Text fields may own the paste; the canvas cache still observes its contents.
self.last_copied_image = None;
self.last_copied_file = None;
if on_canvas {
self.paste_comment_text(view, text);
}
}
ClipboardPaste::Image(image) if on_canvas => {
self.paste_received_image(page, &image);
}
ClipboardPaste::Image(_) => {}
}
}
}
/// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments
/// come from the system clipboard when it holds them, from the app's own only when it holds
/// nothing legible (a word copied in another app since is that word, not the comments copied
/// before it).
fn paste_comment_text(&mut self, i: usize, text: String) {
if pdfcraft_engine::is_xfdf_comments(&text) {
self.paste_comments(i, Some(text), None);
} else if text.trim().is_empty() && self.comment_clipboard.is_some() {
self.paste_comments(i, None, None);
}
}
/// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's
/// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False
/// when nothing is selected.
pub fn copy_selected_comments(&mut self, i: usize) -> bool {
let Some(view) = self.views.get(i) else { return false };
let keys = view.comments.selection();
if keys.is_empty() {
return false;
}
let id = view.id;
match self.session.export_comments(id, &keys) {
Ok(xfdf) => {
if let Some(ctx) = &self.ctx {
ctx.copy_text(xfdf.clone());
}
self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() });
true
}
Err(e) => {
self.notify_error(e);
false
}
}
}
/// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's
/// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu)
/// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one
/// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original
/// and a bundle from a large page goes where it fits on a small one. The copies are selected
/// once placed (see `apply_edit`).
pub fn paste_comments(&mut self, i: usize, text: Option<String>, at: Option<(usize, [f64; 2])>) -> bool {
let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else {
self.notify_tr("Copy comments first");
return false;
};
let Some(view) = self.views.get(i) else { return false };
let id = view.id;
let Some(doc) = self.session.get(id) else { return false };
if !doc.allows_annotation() {
self.notify_tr("The document's security settings don't allow this change");
return false;
}
let count = pdfcraft_engine::comment_count(&xfdf);
let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else {
self.notify_tr("The clipboard holds no comments");
return false;
};
match at {
Some((page, [x, y])) => {
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) })
}
None => {
let Some(view) = self.views.get_mut(i) else { return false };
view.comments.clear_selection();
let outlines = pdfcraft_engine::comment_outlines(&xfdf);
view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines });
self.notify_tr("Click where the copied comments go; Escape lets them go");
true
}
}
}
/// 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.
pub(crate) fn copy_selected_text(&mut self, i: usize, ctx: &egui::Context) -> bool {
// A newer copy wins: a whole-document copy still reading must not replace it later.
self.document_copy = None;
let Some(v) = self.views.get(i) else { return false };
let id = v.id;
if v.all_text && !v.pages_without_text().is_empty() {
// Checked before any page is read: a document that refuses copying reads nothing.
if !self.may_copy(id) {
return false;
}
let Some(job) = self.views.get(i).and_then(|v| Some(DocumentCopyJob::of(v, self.session.get(id)?))) else { return false };
self.document_copy = Some(DocumentCopy::start(id, job, ctx));
return true;
}
let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false };
if !self.may_copy(id) {
return false;
}
// Built only when there is a clipboard to take it.
let html = if self.takes_html() {
self.views
.get(i)
.and_then(DocView::selected_glyphs)
.and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok())
// A capped copy would paste less than the plain text holds; paste that instead.
.filter(|copy| !copy.truncated)
.map(|copy| copy.html)
.filter(|html| !html.is_empty())
} else {
None
};
if !html.is_some_and(|html| self.put_rich_text(&html, &text)) {
ctx.copy_text(text);
}
true
}
/// Whether Copy with formatting has a clipboard to write: the system clipboard is on, and
/// this isn't the web build (or a test stands in for the clipboard).
fn takes_html(&self) -> bool {
self.system_clipboard && (self.html_clipboard_override.is_some() || cfg!(not(target_arch = "wasm32")))
}
/// Put `html` on the clipboard with `text` as its plain alternative, in one set (egui has no
/// HTML output, and its own text copy at the end of the frame would replace the HTML).
/// `false` when it wasn't, so the caller copies the plain text the usual way.
fn put_rich_text(&mut self, html: &str, text: &str) -> bool {
if let Some(set) = self.html_clipboard_override.as_mut() {
return set(html, text);
}
#[cfg(not(target_arch = "wasm32"))]
{
crate::os_clipboard::with_kept(&mut self.os_clipboard, |c| c.set().html(html, Some(text))).is_ok()
}
#[cfg(target_arch = "wasm32")]
{
false
}
}
/// The clipboard text of a whole-document copy, once its pages are read; dropped with the
/// document it was copied from, and by any later copy (a text field's, a comment's), which
/// it must not replace. Run at the end of the frame, so it sees this frame's copies.
pub(crate) fn finish_document_copy(&mut self, ctx: &egui::Context) {
let Some(c) = &self.document_copy else { return };
let copied = ctx.output(|o| o.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_) | egui::OutputCommand::CopyImage(_))));
if copied || !self.views.iter().any(|v| v.id == c.doc) {
self.document_copy = None;
return;
}
match c.rx.try_recv() {
Ok(text) => {
self.document_copy = None;
if !text.is_empty() {
ctx.copy_text(text);
}
}
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)),
Err(std::sync::mpsc::TryRecvError::Disconnected) => self.document_copy = None,
}
}
}
#[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();
app.system_clipboard = false;
app.create_blank();
app.toast = None;
let ctx = egui::Context::default();
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");
}
#[test]
fn review_text_paste_release_is_silent_and_later_image_paste_still_works() {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
app.create_blank();
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()))
.drop_without_applying_deltas();
assert!(app.last_copied_image.is_none());
app.palette_open = false;
app.toast = None;
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 = 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");
}
#[test]
fn review_failed_keyboard_cut_keeps_the_image_selection() {
let mut app = PdfCraftApp::new();
app.system_clipboard = false;
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()))
.drop_without_applying_deltas();
assert!(app.views[0].image_selection.is_some());
}
}