diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index c014453806..2fc44da6f2 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -66,6 +66,8 @@ impl PdfCraftApp { { object_ui::nudge_keys(ctx, &mut self.views[i], doc); object_ui::delete_keys(ctx, &mut self.views[i]); + let held = ctx.input(|input| input.pointer.any_down()); + edit_text_ui::picture_keys(ctx, &mut self.views[i], doc, held); } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 03bd3e807d..f8399e3c3b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -179,6 +179,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut ptr, page: i, info, + press, allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); @@ -458,12 +459,9 @@ impl PageHits<'_> { images, clip, } = *self; - // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). - if tool == QuickTool::EditText - && can_modify - && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) - { - return vec![crop]; + // Crop mode holds every press on its page (`edit_text_ui::crop_hit`). + if tool == QuickTool::EditText && can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == page && s.crop.is_some()) { + return crate::edit_text_ui::crop_hit(xf, page, info, images, view, p).into_iter().collect(); } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index e3b88d1a2c..45162666ce 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -49,6 +49,21 @@ pub(crate) enum PicturePart { Corner(usize), /// The selected picture's rotate knob: a drag turns it. Knob, + /// The picture being cropped, which holds every press on its page. + Crop(CropPart), +} + +/// What of a picture being cropped a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CropPart { + /// A grip of the box kept, in `crop_handles` order (four corners, then the top, right, bottom + /// and left edge midpoints): a drag moves that side or corner. + Grip(usize), + /// The box kept: a drag moves it over the picture. + Keep, + /// The rest of the page, its Done and Cancel controls among it: a press there is crop mode's, + /// and moves nothing. + Page, } /// What of the objects a hit is on. diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f520a91f86..c1a5a6557c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -24,7 +24,7 @@ pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, - quad_contains, user_to_screen, + picture_keys, quad_contains, user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index d3c76f94e2..c0c52001a5 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -2,7 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, use crate::canvas::{ImageObb, PainterExt, image_screen_rect}; use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner, PicturePart}; +use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart}; /// What the pointer and the page are, for the image tools on one page. #[derive(Clone, Copy)] @@ -13,6 +13,8 @@ pub(crate) struct ImageCx<'a> { pub ptr: &'a PagePointer, pub page: usize, pub info: &'a DocInfo, + /// What the press the pages hold is to this page. + pub press: PagePress, pub allow_interaction: bool, } @@ -116,14 +118,29 @@ pub struct CropMode { pub keep: [f64; 4], /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then. pub(crate) drag: Option<(usize, Pos2, [f64; 4])>, + /// Where its Done, Cancel and Remove Crop controls were drawn last (screen): the hand shows + /// over them ([`crop_hit`]). + pub(crate) controls: Vec, } impl CropMode { pub fn new(keep: [f64; 4]) -> Self { - Self { keep, drag: None } + Self { keep, drag: None, controls: Vec::new() } } } +/// How far from a crop grip a press still takes it, and the pointer shows its cursor (screen). +const CROP_GRIP_REACH: f32 = 10.0; + +/// The picture `image` being cropped to `keep`, on screen: its whole frame, and the box kept +/// within it. `None` when either has no area. +fn crop_frames(xf: &PageXform, info: &DocInfo, page: usize, image: &pdfcraft_engine::PageImage, keep: [f64; 4]) -> Option<(Rect, Rect)> { + let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); + let b = if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; + let k = xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + (b.is_finite() && b.is_positive() && b.size().is_finite() && k.is_finite() && k.is_positive()).then_some((b, k)) +} + pub(crate) use pdfcraft_content::Matrix; /// The displayed crop frame, expressed in page user space, rather than the full image placement. @@ -246,12 +263,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) } -fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); - if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) } -} - -/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove +/// Whether `p` is on the crop box or one of its grips (#5835). The Done, Cancel and Remove /// Crop pills are widgets of their own, so they take their clicks before the canvas does. pub(crate) fn crop_controls_contain( image: &pdfcraft_engine::PageImage, @@ -261,35 +273,35 @@ pub(crate) fn crop_controls_contain( page: usize, p: Pos2, ) -> bool { - let b = crop_bounds(image, xf, info, page); - let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { - return false; - } - k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0) + crop_frames(xf, info, page, image, mode.keep) + .is_some_and(|(_, k)| k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < CROP_GRIP_REACH)) } /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` -/// (the pointer and keys are the crop's while it lasts). +/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; - let b = crop_bounds(image, xf, info, page); - let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - let k = keep_screen(mode.keep); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { + let Some((b, k)) = crop_frames(xf, info, page, image, mode.keep) else { mode.drag = None; return true; - } + }; + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + // A drag takes what its press was on ([`crop_hit`]). if allow_interaction && resp.drag_started() && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press { - let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8)); + let grip = match part { + CropPart::Grip(g) => Some(g), + CropPart::Keep => Some(8), + CropPart::Page => None, + }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); } @@ -352,7 +364,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e // Crop handles for (idx, h) in crop_handles(k).iter().enumerate() { let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx); - let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0); + let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < CROP_GRIP_REACH); draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT); } @@ -385,6 +397,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0)); let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0)); let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0))); + mode.controls = [Some(done), Some(cancel), reset].into_iter().flatten().collect(); let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i); let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i); @@ -430,26 +443,11 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e neutral_pill(r, response, tl!("Remove Crop")); } - if let Some(p) = hover_pos { - let icon = if done_resp.hovered() - || cancel_resp.hovered() - || reset_resp.as_ref().is_some_and(|r| r.hovered()) - || done.contains(p) - || cancel.contains(p) - || reset.is_some_and(|r| r.contains(p)) - { - egui::CursorIcon::PointingHand - } else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) { - g.cursor_icon() - } else if k.contains(p) { - if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } - } else { - egui::CursorIcon::Default - }; - view.cursor.claim(Layer::Editor, icon); + // The cursor is the hit's ([`crop_hit`]), and a grip's or the box's being dragged. + if let Some((grip, ..)) = mode.drag { + view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon())); } - let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); let reset_clicked = allow_interaction @@ -459,27 +457,60 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e if reset_clicked { mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep }; mode.drag = None; - } else if done_clicked || keys(egui::Key::Enter) { - let keep = mode.keep; - if let Some(s) = view.image_selection.as_mut() { - s.crop = None; - } - let is_full = same_rect(keep, image.rect); - if is_full { - if image.has_editor_crop() { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop }); - } - } else if image.visible_rect != Some(keep) { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); - } - } else if (cancel_clicked || keys(egui::Key::Escape)) - && let Some(s) = view.image_selection.as_mut() - { + } else if done_clicked { + apply_crop_mode(view, image); + } else if cancel_clicked && let Some(s) = view.image_selection.as_mut() { s.crop = None; } true } +/// Done in crop mode: it ends, and the picture `image` of the selection keeps the box kept (or +/// all of itself again). +fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) { + let Some(s) = view.image_selection.as_mut() else { return }; + let Some(keep) = s.crop.take().map(|mode| mode.keep) else { return }; + let (page, index) = (s.page, s.index); + if same_rect(keep, image.rect) { + if image.has_editor_crop() { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::RemoveCrop }); + } + } else if image.visible_rect != Some(keep) { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } +} + +/// The selected picture's keys, from the app's own (none reach it under a dialog): Delete or +/// Backspace removes it, wherever the pointer is, unless it is being dragged or cropped. In crop +/// mode Enter applies the crop and Escape leaves it, though not while a button is down (`held`): +/// Escape then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode +/// stays. Nothing while a text field has the keyboard. +pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, doc: &pdfcraft_engine::Document, held: bool) { + if ctx.egui_wants_keyboard_input() { + return; + } + let Some(s) = view.image_selection.as_ref() else { return }; + let (page, index) = (s.page, s.index); + let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k)); + // The page's pictures are read only when a key asks for one. + let image = || doc.page_images(page).into_iter().nth(index); + if s.crop.is_some() { + if key(egui::Key::Enter) { + if let Some(image) = image() { + apply_crop_mode(view, &image); + } + } else if !held + && key(egui::Key::Escape) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + } + } else if !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image().is_some() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete }); + } +} + /// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where /// the screen may still show the picture). fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { @@ -541,12 +572,33 @@ pub(crate) fn handle_hit( Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle))) } -/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its -/// frame included, so the paragraphs under it and the empty page's marquee never take a crop -/// button's press before the picture does. `None` when no picture on `page` is being cropped. -pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option { - let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?; - Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default)) +/// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the +/// controls beside its frame included, so the paragraphs under it and the empty page's marquee +/// never take a crop button's press before the picture does. A grip of the box kept (within +/// [`CROP_GRIP_REACH`]), the box itself, or the rest of the page; and the controls past the page's +/// edge, where they show the hand. `None` when no picture on `page` is being cropped, or `p` is off +/// the page and its controls. +pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { + let s = view.image_selection.as_ref().filter(|s| s.page == page)?; + let mode = s.crop.as_ref()?; + let control = mode.controls.iter().any(|r| r.contains(p)); + if !control && !xf.rect.contains(p) { + return None; + } + let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); + if control { + return crop(CropPart::Page, egui::CursorIcon::PointingHand); + } + let Some((_, k)) = images.get(s.index).and_then(|image| crop_frames(xf, info, page, image, mode.keep)) else { + return crop(CropPart::Page, egui::CursorIcon::Default); + }; + if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH) { + return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + } + if k.contains(p) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } + crop(CropPart::Page, egui::CursorIcon::Default) } /// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the @@ -628,7 +680,7 @@ pub fn image_menu_id() -> egui::Id { /// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. #[allow(clippy::cognitive_complexity)] pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction, .. } = *cx; let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -949,14 +1001,6 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage view.cursor.claim(Layer::Drag, cursor_icon); return true; } - if allow_interaction - && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() - { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); - return true; - } } if !allow_interaction { return false; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c4231d0f69..5be20ce51b 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7230,6 +7230,123 @@ fn cropping_a_picture_with_its_handles() { assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none())); } +/// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0 +/// selected (and being cropped, with `crop`). +fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + let id = h.state().views[0].id; + let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = crop.then(|| pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + h +} + +/// The screen point at PDF (x, y) of `page` of [`picture_pages`]. +fn page_at(h: &Harness<'static, PdfCraftApp>, page: usize, x: f32, y: f32) -> egui::Pos2 { + let r = h.state().views[0].page_screen_rect(page).expect("both pages on screen"); + egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()) +} + +fn cursor_over(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// Crop mode holds every press on its page, and its cursor stays there: the grips show theirs and +/// the box kept the hand that drags it, and on the next page the pointer shows what a press there +/// takes (a paragraph's move arrow). Crop mode's cursor used to win on every page. +#[test] +fn crop_modes_cursor_stays_on_its_page() { + let mut h = picture_pages(true); + // The visible picture is x 30..160, y 170..250. + for (page, x, y, cursor, what) in [ + (0, 160.0, 170.0, egui::CursorIcon::ResizeNwSe, "a corner grip"), + (0, 95.0, 210.0, egui::CursorIcon::Grab, "the box kept"), + (0, 65.0, 134.0, egui::CursorIcon::Default, "crop mode holds its page's paragraphs"), + (1, 65.0, 134.0, egui::CursorIcon::Move, "the next page's paragraph"), + ] { + let p = page_at(&h, page, x, y); + assert_eq!(cursor_over(&mut h, p), cursor, "{what}"); + } +} + +/// A press on a crop grip a little outside the picture drags that grip, and Escape in the middle +/// of the drag cancels it: the box goes back and crop mode stays. Escape once the button is up +/// leaves crop mode. +#[test] +fn escape_during_a_crop_drag_keeps_crop_mode() { + let mut h = picture_pages(true); + let before = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep; + let grip = page_at(&h, 0, 160.0, 170.0) + egui::vec2(5.0, 5.0); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip - egui::vec2(10.0, 10.0) * k as f32); + h.run_steps(1); + } + let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep(&h).is_some_and(|k| k[2] < before[2] - 5.0 && k[1] > before[1] + 5.0), "the grip outside the picture is dragged"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "Escape cancels the drag, and crop mode stays"); + h.drop_at(grip - egui::vec2(40.0, 40.0)); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "the cancelled drag's release crops nothing"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), None, "Escape leaves crop mode"); + assert!(!dirty(&h)); +} + +/// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the +/// app's, not the pointer's. +#[test] +fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() { + let mut h = picture_pages(false); + let caption = page_at(&h, 0, 65.0, 134.0); + assert_eq!(cursor_over(&mut h, caption), egui::CursorIcon::Move, "on the caption's paragraph"); + h.key_press(Key::Delete); + h.run_steps(3); + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is deleted"); + assert!(h.state().views[0].image_selection.is_none()); +} + +/// With a dialog open, Delete is the dialog's: the selected picture stays, and so does crop mode +/// under Escape and Enter. +#[test] +fn a_dialog_keeps_its_delete_while_a_picture_is_selected() { + let mut h = picture_pages(false); + let on_picture = page_at(&h, 0, 95.0, 210.0); + h.hover_at(on_picture); + h.run_steps(1); + assert!(h.state_mut().execute("app.preferences")); + h.run_steps(3); + for key in [Key::Delete, Key::Backspace] { + h.key_press(key); + h.run_steps(3); + } + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "the picture stays under the dialog"); + assert!(h.state().views[0].image_selection.is_some()); + assert_eq!(h.state().dialog, Some(Dialog::Preferences)); +} + /// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the /// panel's Close ends Organize: the grid closes and All tools comes back. #[test]