diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ff5152c43..140c38b53e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1242,11 +1242,10 @@ struct PageFrame<'a> { } impl PageFrame<'_> { - /// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages - /// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one - /// page's, as a press is. - fn right_click_on(&mut self, rect: Rect) -> Option { - let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?; + /// Where this frame's right-click is, for the first page it is `on`: pages that touch (the gaps + /// hidden) can share a row of pixels, and a right-click there is one page's, as a press is. + fn right_click_on(&mut self, on: impl FnOnce(Pos2) -> bool) -> Option { + let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && on(*p))?; self.right_clicked = true; Some(p) } @@ -1545,7 +1544,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = frame.right_click_on(xf.rect) { + if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { comments::anchor(&pcx, p, view); } PageInput { diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 543f5f963d..e96a2b8430 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -22,8 +22,10 @@ pub struct PickedImage { const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); /// A click on page `page` (Select tool, not taken by anything else) selects the picture under -/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double -/// click still selects text, and clears the picture. +/// it, even with text drawn over it (scans with an OCR layer, captions), with `pick` (a press +/// nobody holds: a link's click follows the link only); dragging or a double click still selects +/// text, and clears the picture. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( resp: &egui::Response, xf: &PageXform, @@ -31,6 +33,7 @@ pub(crate) fn page_input( info: &DocInfo, doc: &pdfcraft_engine::Document, ptr: &crate::page_pointer::PagePointer, + pick: bool, view: &mut DocView, ) { let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return }; @@ -38,7 +41,7 @@ pub(crate) fn page_input( view.picked_image = None; return; } - if !resp.clicked() { + if !resp.clicked() || !pick { return; } let Some(info_page) = info.pages.get(page) else { return }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5458dc1e44..958c26eca0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -108,9 +108,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && !egui::Popup::is_any_open(ui.ctx()); let objects = adds.then(|| view.objects.inventory(doc, i)); let at = hits.at(view, o, objects.as_ref()); - // A press is the page's it lies on, or the one with something under it past its edge (the - // selected picture's rotate knob and grips). - if xf.rect.contains(o) || !at.is_empty() { + // A press is the page's it lies on, or past its edge the one whose selected picture has its + // rotate knob or a grip there (crop mode's grips too). + let past_edge = |h: &crate::page_router::Hit| { + use crate::page_router::PicturePart; + matches!(h.owner, Owner::Picture { part: PicturePart::Knob | PicturePart::Corner(_) | PicturePart::Crop(_), .. }) + }; + if xf.rect.contains(o) || at.iter().any(past_edge) { view.router.press(i, at, tool); } } @@ -126,6 +130,16 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let wants_under = editing || resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); let hovered = top(under.iter().cloned(), tool); + // A double-click on the objects selected together is the object's under it: the double-click + // leaves the group (`object_ui`), and added text opens for typing, a picture or a paragraph is + // the one taken (the user's ruling). + let press = match press { + PagePress::Mine(Owner::Objects { part: crate::page_router::ObjectsPart::Selection }) if resp.double_clicked() => { + top(under.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool) + .map_or(PagePress::Free, |h| PagePress::Mine(h.owner)) + } + press => press, + }; let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -135,7 +149,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // released, the right-click's own frame included, so the button is what tells. A touch's long // press, which egui reports as a right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); - let right = frame.right_click_on(xf.rect); + // A right-click is the page's it lies on, or past its edge the one whose crop grip lies there, + // as a press is (#5835): what a right-click there would go to is asked only off the page. + let right = frame.right_click_on(|p| { + use crate::page_router::PicturePart; + xf.rect.contains(p) || matches!(hits.context_at(view, p, &[]), Some(Owner::Picture { part: PicturePart::Crop(_), .. })) + }); let context = right.and_then(|p| right_click(&hits, &under, view, p, press_held)); // Edit text: a right-click on a picture selects it for its menu, and one on anything else on the // page unselects it. @@ -794,9 +813,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A press nobody holds picks the picture under it: a link's click follows the link only (ruling - // 5). - if tool == QuickTool::Select && input.press == PagePress::Free && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, view); + // 5), though its drag or double-click still lets the picked picture go. + if tool == QuickTool::Select && matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view); } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); 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 50ec5ec78b..829a6d284b 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -571,40 +571,41 @@ pub(crate) fn handle_hit( /// 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, the picture outside it (its frame shown or its whole frame), -/// 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. +/// [`CROP_GRIP_REACH`], past the page's edge too), the box itself, the picture outside it (its frame +/// shown or its whole frame), 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, +/// its grips and its controls: the box and the picture stop at the page's edge, as a clipped +/// artwork's whole frame can reach over the next pages. 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 { + if mode.controls.iter().any(|r| r.contains(p)) { return crop(CropPart::Page, egui::CursorIcon::PointingHand); } - let Some(image) = images.get(s.index) else { return crop(CropPart::Page, egui::CursorIcon::Default) }; - if let Some((_, k)) = crop_frames(xf, info, page, image, mode.keep) { - 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); - } + let image = images.get(s.index); + let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k); + // The grips reach past the page's edge, where those of a picture filling its page lie half. + if let Some(g) = kept.and_then(|k| 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())); } - let shown = ImageObb::from_image(image, xf, info, page); - let whole = ImageObb::from_matrix(image.matrix, xf, info, page); - if quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) { - return crop(CropPart::Cut, egui::CursorIcon::Default); + if !xf.rect.contains(p) { + return None; } - crop(CropPart::Page, egui::CursorIcon::Default) + if kept.is_some_and(|k| k.contains(p)) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } + let on_picture = image.is_some_and(|image| { + let shown = ImageObb::from_image(image, xf, info, page); + let whole = ImageObb::from_matrix(image.matrix, xf, info, page); + quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) + }); + crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default) } -/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the -/// selected one. On the text layer, asked before the paragraphs: editable text on top of a -/// picture takes the press. +/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it +/// included): a click selects it, a drag moves the selected one. On the text layer, asked before +/// the paragraphs: editable text on top of a picture takes the press. pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { if !xf.rect.contains(p) { return None; @@ -613,7 +614,12 @@ pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pd let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from); let index = images .iter() - .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?; + .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user)) + .or_else(|| { + let s = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none())?; + let image = images.get(s.index)?; + quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p).then_some(s.index) + })?; let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index); let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon)) @@ -966,16 +972,19 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - // A click selects the picture its press was on; its cursor is the hit's. + // A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this + // page leaves it unselected (where the page's objects can be selected together, the area + // selection the press starts has done so already). let owner = match press { PagePress::Mine(Owner::Picture { index, .. }) => Some(index), _ => None, }; - if gate - && resp.clicked() - && let Some(index) = owner - { - view.image_selection = Some(ImageSelection::at(page, index)); + if gate && resp.clicked() { + match owner { + Some(index) => view.image_selection = Some(ImageSelection::at(page, index)), + None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, + None => {} + } } owner.is_some() } diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index e504aaecf1..2ae03c4083 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -187,6 +187,10 @@ fn leave_single(view: &mut DocView) { /// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the /// caret. pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, open: Option, p: Pos2) -> Option { + // Past the page's edge is no object's: the next page lies there. + if !xf.rect.contains(p) { + return None; + } let s = &view.objects; if s.page == Some(page) && !s.selected.is_empty() && s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))).contains(p) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index af0d02b9c6..d505ca2873 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6503,6 +6503,15 @@ fn clipped_artwork_with_labels() -> Vec { /// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049). fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { + artwork_drawn(if own_clip { + &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + } else { + &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + }) +} + +/// [`fixture`]'s two pages, each drawn by `content`, with a one-pixel picture as `/Im0`. +fn artwork_drawn(content: &[u8]) -> Vec { use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap(); let mut image = Dict::new(); @@ -6514,12 +6523,7 @@ fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); for page in pdfcraft_model::pages(&doc) { - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), - if own_clip { - &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - } else { - &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - }))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); let mut xo = Dict::new(); xo.set(b"Im0".to_vec(), Object::Ref(image)); @@ -7233,10 +7237,15 @@ fn cropping_a_picture_with_its_handles() { /// 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| { + picture_pages_of(clipped_artwork_with_labels(), crop) +} + +/// [`picture_pages`], of `pdf`. +fn picture_pages_of(pdf: Vec, crop: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap(); + app.open_bytes("clipped.pdf", None, pdf).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "100").unwrap(); app @@ -7355,6 +7364,99 @@ fn in_crop_mode_a_right_click_on_another_picture_selects_it() { assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); } +/// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the +/// picture: its frame is its own. The press fell through the hole, started an area selection and +/// unselected it. +#[test] +fn a_drag_from_a_hole_in_the_selected_picture_moves_it() { + // The picture shows at x 30..160, y 170..250, around a hole at x 70..120, y 190..230. + let mut h = picture_pages_of(artwork_drawn(b"q 30 170 130 80 re 70 190 50 40 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q"), false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let hole = page_at(&h, 0, 95.0, 210.0); + h.hover_at(hole); + h.run_steps(1); + h.drag_at(hole); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(hole + egui::vec2(5.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(hole + egui::vec2(20.0, 0.0)); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(after[0] - before[0] > 5.0, "the picture moved right: {before:?} → {after:?}"); +} + +/// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie +/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835). No page took it, +/// which left crop mode. +#[test] +fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + 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 = Some(pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + crop_secondary_click(&mut h, r.right_bottom() + egui::vec2(4.0, 4.0)); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode stays"); + for label in ["Flip horizontal", "Rotate view clockwise"] { + assert!(h.query_all_by_label(label).next().is_none(), "no menu: {label}"); + } +} + +/// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie +/// half: a drag from just outside the page's corner trims the picture. Only the half on the page took +/// the press. +#[test] +fn a_crop_grip_past_the_page_edge_drags() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let before = 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 = Some(pdfcraft_ui_egui::CropMode::new(before)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let outside = r.right_bottom() + egui::vec2(4.0, 4.0); + let to = r.right_bottom() - egui::vec2(r.width() / 4.0, r.height() / 4.0); + h.hover_at(outside); + h.run_steps(1); + h.drag_at(outside); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(outside + (to - outside) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(2); + let keep = h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep.is_some_and(|k| k[2] < before[2] - 1.0 && k[1] > before[1] + 1.0), "trimmed from the corner: {keep:?} of {before:?}"); +} + /// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the /// app's, not the pointer's. #[test] diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 54dcfa5f33..8a74fea261 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -438,3 +438,16 @@ fn a_press_just_outside_a_picture_starts_an_area_selection() { h.run_steps(4); h.get_by_label("2 objects selected"); } + +/// A double-click on one of the objects selected together leaves the group and reaches that one: +/// added text opens for typing. The double-click only left the group. +#[test] +fn a_double_click_on_a_grouped_object_reaches_it() { + let mut h = harness(); + marquee(&mut h); + // The added note is x 130..230, y 130..144 from the page's top. + let note = at(&h, 180.0, 137.0); + crate::support::input::double_click(&mut h, note); + h.run_steps(2); + assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "the added note opens for typing"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1bf7931122..3230ceb83f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -81,6 +81,31 @@ fn linked_picture_pdf() -> Vec { ]) } +/// Two 300×400 pages, the first with a line of text at its bottom edge, at y 4, the second with one +/// near its top, at y 380. +fn bottom_line_pages_pdf() -> Vec { + let bottom = stream("BT /F1 12 Tf 40 4 Td (Bottom line) Tj ET"); + let top = stream("BT /F1 12 Tf 40 380 Td (Top line) Tj ET"); + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + bottom.as_str(), + top.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a picture at x 50..250, y 250..350, and graphics states nested deeper than +/// the objects selected together are read through: they can't be selected together on this page. +fn ungroupable_picture_pdf() -> Vec { + let picture = + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream"; + let content = stream(&format!("q 200 0 0 100 50 250 cm /Im0 Do Q {}{}", "q ".repeat(1030), "Q ".repeat(1030))); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", content.as_str(), picture]) +} + /// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. fn comment_on_text_pdf() -> Vec { let text = stream(LINE); @@ -1867,6 +1892,62 @@ fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() assert_eq!(selected, Some((0, 0)), "the picture is selected for its menu"); } +/// With the gaps between pages hidden, a drag from the top of page 2 under page 1's open paragraph is +/// page 2's: its area selection takes the line there. The paragraph's frame, which reaches past +/// page 1's edge, took the press and did nothing with it. +#[test] +fn a_drag_past_a_pages_edge_under_its_open_paragraph_is_the_next_pages() { + let mut h = open(bottom_line_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let line = egui::pos2(one.min.x + 60.0, one.max.y - 8.0); + mouse_click(&mut h, line); + assert!(h.state().views[0].line_editor.is_some(), "the bottom line is open"); + let below = egui::pos2(one.min.x + 60.0, two.min.y + 3.0); + mouse_drag(&mut h, below, egui::vec2(200.0, 30.0)); + // What the area selection took, Delete removes. + h.key_press(egui::Key::Delete); + h.run_steps(4); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().text_blocks(1).is_empty(), "page 2's area selection took its line"); +} + +/// With Edit text, a click on the empty page leaves the selected picture unselected, on a page whose +/// objects can't be selected together too, where no area selection starts to do it. It stayed. +#[test] +fn a_click_beside_the_picture_unselects_it_where_objects_cant_be_grouped() { + let mut h = open(ungroupable_picture_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 1, "the picture"); + assert!(doc.editable_objects(0).is_err(), "and no objects to group"); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(2); + let blank = at(&h, 150.0, 100.0); + mouse_click(&mut h, blank); + assert!(h.state().views[0].image_selection.is_none(), "the picture is unselected"); +} + +/// With the Select tool a drag that starts on a link lets the picked picture go, as a drag elsewhere +/// does, though the link's click picks none. +#[test] +fn a_drag_from_a_link_lets_the_picked_picture_go() { + let mut h = open(linked_picture_pdf(), "150", false); + let beside = at(&h, 70.0, 300.0); + mouse_click(&mut h, beside); + assert!(h.state().views[0].picked_image.is_some(), "the picture is picked"); + let on_link = at(&h, 150.0, 300.0); + mouse_drag(&mut h, on_link, egui::vec2(30.0, 0.0)); + assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go"); +} + /// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is /// started under it, as before. #[test]