diff --git a/AGENTS.md b/AGENTS.md index c228c4185b..b68d8c8921 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ PdfCraft opens files from strangers, and people trust it with their work. A malf - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd. `cos`, `filters`, `crypt` and `arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state. `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it. - **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim`, `crates/ui-egui/src/canvas/page_cursor.rs`) and never set it themselves. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what is under a point in one hit test (`Hit` in `crates/ui-egui/src/canvas/page_router.rs`) and acts on a press only through that router. Modules never read the router; they are told. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. - **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it. - **Fonts** are the optional build input `CRAFT_FONTS_DIR`. Code using `pdfcraft_fonts::CRAFT_FONTS` must work when it is empty. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check. New workspace crates include `workspace = true` under `[lints]`. Scope `cargo clippy -p --all-targets -- -D warnings` and `cargo test -p ` to the crates you touched; the workspace sweep is CI's job. diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 50257d8248..12a1e3aecd 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -175,6 +175,11 @@ impl PageText { .collect() } + /// Index of the first glyph whose box holds a view-space point: the text under the pointer. + pub fn glyph_at(&self, x: f32, y: f32) -> Option { + self.glyphs.iter().position(|g| x >= g.rect[0] && x <= g.rect[2] && y >= g.rect[1] && y <= g.rect[3]) + } + /// Index of the glyph nearest to a view-space point (for selection anchors). /// /// A glyph whose drawn em box (`quad`) holds the point wins. Skewed or turned text has bounding diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a1af42a8d0..8776dfeb72 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1965,3 +1965,18 @@ fn text_outside_the_crop_box_is_clipped() { assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text()); } } + +/// The glyph under a point is the first whose box holds it, edges included; between glyphs, none. +#[test] +fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { + let text = PageText { + glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], + line_of: vec![0, 0], + space_before: vec![false, false], + }; + assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); + assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); + assert_eq!(text.glyph_at(20.0, 10.0), Some(1), "an edge is in"); + assert_eq!(text.glyph_at(25.0, 5.0), None); + assert_eq!(PageText::default().glyph_at(0.0, 0.0), None); +} diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0d210e2e04..dc9c4fd35c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -608,9 +608,15 @@ impl eframe::App for PdfCraftApp { // opening a modal/palette, or returning to a window that lost focus. let blocked = self.modal_open() || self.palette_open || !ctx.input(|i| i.focused); for (index, view) in self.views.iter_mut().enumerate() { - view.objects.set_input_blocked(blocked || self.active != Some(index)); - if blocked || self.active != Some(index) { + let shut = blocked || self.active != Some(index); + view.objects.set_input_blocked(shut); + if shut { view.auto_scroll.cancel(); + // Edit text and Add text's drags end while their input is blocked rather than wait, to + // land wherever the pointer is once it opens again. + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { + view.cancel_edit_text_gestures(); + } } } // A field that refused its value this frame keeps the shortcut from saving or printing diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9004d88205..5ee2a7272d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -255,22 +255,6 @@ impl ImageObb { } } -/// Whether pointer `p` is on an image being cropped, its crop box or its handles (#5835). -pub(crate) fn crop_contains_point( - img: &pdfcraft_engine::PageImage, - crop: &crate::edit_text_ui::CropMode, - xf: &PageXform, - info: &DocInfo, - page: usize, - p: Pos2, -) -> bool { - let obb = ImageObb::from_image(img, xf, info, page); - let full_obb = ImageObb::from_matrix(img.matrix, xf, info, page); - crate::edit_text_ui::quad_contains(obb.corners(), p) - || crate::edit_text_ui::quad_contains(full_obb.corners(), p) - || crate::edit_text_ui::crop_controls_contain(img, crop, xf, info, page, p) -} - /// The page display as it was before full screen, put back on leaving it /// ([`DocView::enter_presentation`]). #[derive(Clone, Copy, Debug, PartialEq)] @@ -676,8 +660,13 @@ pub struct DocView { pub(crate) signature_drag: crate::signature_drag::SignatureDrag, /// What the page modules claim for the cursor this frame; `document_area` sets it once. pub(crate) cursor: crate::page_cursor::PageCursor, - /// Who the press the pages hold belongs to. - pub(crate) router: crate::page_router::PageRouter, + /// Who the press the pages hold belongs to. Only the page view reads it: it tells each page + /// module what the press is to its page (`PagePress`). + pub(in crate::canvas) router: crate::page_router::PageRouter, + /// What the last right-click on a page went to, and on which page (`None` off the pages, and for + /// Add text, which takes every right-click): the page's menu shows its entries when it has a menu + /// and is the one selected, or else the page's own. + pub(in crate::canvas) menu_for: Option<(usize, crate::page_router::Owner)>, /// Escape cancelled the press the pages hold: the page ignores it until the button is up, so /// its release neither clicks nor ends a drag. pub(crate) press_cancelled: bool, @@ -906,13 +895,20 @@ impl DocView { if let Some(q) = &mut self.camera { q.grab = None; } - self.block_drag = None; self.org_drag = None; self.panel_drag = None; self.selection_drag = false; self.router.cancel(); self.auto_scroll.cancel(); self.prepare.cancel_pointer_gesture(); + self.cancel_edit_text_gestures(); + } + + /// Abandon Edit text's pointer gestures (a paragraph moved or rewrapped, a picture moved, + /// resized, turned or cropped, added content moved, the objects' area selection or move), as + /// [`Self::cancel_pointer_gestures`] does: what they were previewing is not applied. + pub(crate) fn cancel_edit_text_gestures(&mut self) { + self.block_drag = None; self.content.cancel_pointer_gesture(); self.objects.cancel_pointer_gesture(); if let Some(image) = &mut self.image_selection { @@ -1060,6 +1056,7 @@ impl DocView { signature_drag: Default::default(), cursor: Default::default(), router: Default::default(), + menu_for: None, press_cancelled: false, } } @@ -1272,11 +1269,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) } @@ -1575,10 +1571,17 @@ 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 { consumed: true, area_tool: false, hover: None, released_over: false } + PageInput { + press: crate::page_router::PagePress::Elsewhere, + text_drag: false, + consumed: true, + area_tool: false, + hover: None, + released_over: false, + } } else { page_tool_input(ui, view, frame, &slot) }; @@ -1638,8 +1641,6 @@ fn draw_pages( prefs, preparing, selection_wheel, - ppp, - rects, single_paging, tool, unobstructed, @@ -1708,54 +1709,10 @@ fn draw_pages( view.links.context = None; // Set again below by the page right-clicked: a right-click off the pages (between them, // beside one) anchors no page's menu, which then acts on the current page. It deselects the - // comment too (below), as one on a page's empty part does. A selected Prepare field keeps - // its menu, off the pages as on them. + // comment, and Edit text's picture, too (below), as one on a page's empty part does. A + // selected Prepare field stays selected, and the menu is the page's there too. view.comments.context_at = None; - let mut over_image = false; - if tool == QuickTool::EditText { - let pointer_pos = ptr.pos; - if let Some(p) = pointer_pos { - for &page_idx in visible_pages { - let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp); - if !r.intersects(visible) { - continue; - } - let xf = - PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) }; - let images = cached_edit_images(view, doc, page_idx).to_vec(); - // Crop controls own the click even when another image overlaps them. - if view.image_selection.as_ref().is_some_and(|s| { - s.page == page_idx - && s.crop - .as_ref() - .zip(images.get(s.index)) - .is_some_and(|(crop, img)| crop_contains_point(img, crop, &xf, info, page_idx, p)) - }) { - over_image = true; - break; - } - for (idx, img) in images.iter().enumerate().rev() { - let obb = ImageObb::from_image(img, &xf, info, page_idx); - let b = image_screen_rect(img, &xf, info, page_idx); - let selected_crop = view.image_selection.as_ref().is_some_and(|s| s.page == page_idx && s.index == idx && s.crop.is_some()); - let hit = if selected_crop { crate::edit_text_ui::quad_contains(obb.corners(), p) } else { obb.contains(b, p) }; - if hit { - over_image = true; - if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) { - view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx)); - } - break; - } - } - if over_image { - break; - } - } - } - } - if !over_image && tool == QuickTool::EditText { - view.image_selection = None; - } + view.menu_for = None; } let mut frame = PageFrame { env: *env, @@ -1793,9 +1750,13 @@ fn draw_pages( right_clicked, .. } = frame; - // A right-click no page took deselects the comment, so the page's menu opens, not the comment's. + // A right-click no page took deselects the comment, so the page's menu opens, not the comment's, + // and with Edit text the picture (one on a page went to that page, `page_input`). if resp.secondary_clicked() && !right_clicked { view.comments.selected = None; + if tool == QuickTool::EditText { + view.image_selection = None; + } } let PageOut { mut canvas_action, @@ -1867,28 +1828,34 @@ fn draw_pages( if view.continuous { view.current = current; } + // The menu of what the right-click went to (`menu_for`), or else the page's. + use crate::page_router::{Owner, PicturePart}; + let menu_for = view.menu_for.map(|(_, owner)| owner); + // A right-click while a press is held leaves the selections alone, so the menu is its owner's only + // when that owner is the one selected. + let on_selected_field = match menu_for { + Some(Owner::PrepareField { field, widget }) => { + form.get(field).is_some_and(|f| view.prepare.selected.as_ref().is_some_and(|(name, w)| *name == f.name && *w == widget)) + } + Some(Owner::PrepareCorner { .. }) => true, + _ => false, + }; // The row as it is now: in continuous view the current page may have changed above. let row_on_screen = shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); resp.context_menu(|ui| { - let is_crop_over_image = tool == QuickTool::EditText - && view.image_selection.clone().is_some_and(|sel| { - let Some(ref crop) = sel.crop else { return false }; - let pointer_pos = ptr.pos; - let Some(p) = pointer_pos else { return false }; - let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; - let images = cached_edit_images(view, doc, sel.page).to_vec(); - let Some(img) = images.get(sel.index) else { return false }; - crop_contains_point(img, crop, &xf, info, sel.page, p) - }); - if is_crop_over_image { - ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); + // Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is + // the crop's (#5835). + if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) { egui::Popup::close_all(ui.ctx()); ui.close(); return; } - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Preparing a form: the selected field's menu, on a field. + if preparing + && on_selected_field + && let Some((name, wi)) = view.prepare.selected.clone() + { // Several fields: Align, Center, Distribute, Set Fields to Same Size. if !view.prepare.also.is_empty() { use crate::prepare::Arrange as A; @@ -1935,7 +1902,9 @@ fn draw_pages( } return; } - if let Some((page, index)) = view.links.context { + if matches!(menu_for, Some(Owner::Link { .. })) + && let Some((page, index)) = view.links.context + { if ui.button(tl!("Properties…")).clicked() { view.links.open_existing = Some((page, index)); ui.close(); @@ -1956,31 +1925,24 @@ fn draw_pages( return; } let can_paste_image = env.can_paste_image; - let is_image_menu = tool == QuickTool::EditText - && (ui.ctx().data_mut(|d| d.get_temp::(crate::edit_text_ui::image_menu_id())).unwrap_or(false) - || view.image_selection.clone().is_some_and(|sel| { - let pointer_pos = ptr.pos; - let Some(p) = pointer_pos else { return false }; - let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false }; - let images = cached_edit_images(view, doc, sel.page).to_vec(); - let Some(img) = images.get(sel.index) else { return false }; - let obb = ImageObb::from_image(img, &xf, info, sel.page); - let b = image_screen_rect(img, &xf, info, sel.page); - obb.contains(b, p) - })); - if is_image_menu && let Some(sel) = view.image_selection.clone() { + if tool == QuickTool::EditText + && let Some((page, Owner::Picture { index, .. })) = view.menu_for + && let Some(sel) = view.image_selection.clone().filter(|s| (s.page, s.index) == (page, index)) + { let images = cached_edit_images(view, doc, sel.page).to_vec(); - // Paste lands where the menu opened only on the picture's page: where pages touch, the - // first page takes the right-click, and its point means nothing on the second. + // Paste lands where the menu opened, on the picture's page: the right-click that opened + // it went to that page (`page_input`), and another page's point means nothing there. let at = view.comments.context_at.filter(|(page, _)| *page == sel.page).map(|(_, at)| at); let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360; crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation); return; } - // A selected comment, or connected lines being drawn (Complete, Undo last point): the - // comment menu. + // The comment the right-click went to, when it is the one selected, or connected lines being + // drawn (Complete, Undo last point): the comment menu. + let on_selected_comment = + matches!(view.menu_for, Some((page, Owner::Comment { index, .. })) if view.comments.selected == Some((page, index))); let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points()); - if view.comments.selected.is_some() || drawing_points { + if on_selected_comment || drawing_points { canvas_action = comments::context_menu( ui, view, @@ -2037,9 +1999,6 @@ fn draw_pages( }; default_page_context_menu(ui, page, at, perms, &mut page_action); }); - if !resp.context_menu_opened() { - ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false)); - } // Copy comments / Paste comments from the page's menu or a Comments panel card. let copy_comments = std::mem::take(&mut view.comments.copy_request); let paste_comments_at = view.comments.paste_request.take(); diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index cc938170da..e96a2b8430 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -3,7 +3,7 @@ //! other image as a lossless PNG (with its transparency). The clipboard gets the file, as when a //! file is copied in Finder or Explorer; Copy Image copies the pixels instead. -use egui::{Color32, CornerRadius, Pos2, Stroke}; +use egui::{Color32, CornerRadius, Stroke}; use crate::PdfCraftApp; use crate::canvas::{DocView, PageXform}; @@ -22,23 +22,26 @@ 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, page: usize, info: &DocInfo, doc: &pdfcraft_engine::Document, - pointer: Option, + ptr: &crate::page_pointer::PagePointer, + pick: bool, view: &mut DocView, ) { - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return }; - if resp.drag_started() || resp.double_clicked() || resp.triple_clicked() { + let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return }; + if ptr.drag_started || resp.double_clicked() || resp.triple_clicked() { 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 e1c9719668..1e2b1e567d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -12,7 +12,13 @@ use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. pub(super) struct PageInput { - /// A tool, a field or a comment took the pointer: text selection must leave it alone. + /// What the press the pages hold is to this page. + pub(super) press: PagePress, + /// The press's drag is the page's text's: nobody holds it, or its owner lets its drag go (text + /// markup: a click selects it, a drag selects the text under it). + pub(super) text_drag: bool, + /// A tool, a field or a comment took the pointer this frame (its own press, or a gesture of the + /// active tool's): text selection and image pick leave it alone. pub(super) consumed: bool, /// Redact or Highlight draws boxes off text. pub(super) area_tool: bool, @@ -37,8 +43,9 @@ pub(crate) struct PageToolContext<'a> { /// The tools' input on one page. The hits at the press point decide who holds the press, and the /// modules are told what the press is to this page (`PagePress`): their own, nobody's (the active -/// tool's: `tool_input`, and the tools still called here), or another page's. The Edit text group -/// still asks the router, until it is told too. Text selection runs after, on what nobody took. +/// tool's: `tool_input`, and the tools still called here), or another page's. Edit text's group +/// paints from what lies under the pointer too (`hovered`). Text selection runs after, on what +/// nobody took. pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { let PageEnv { added, @@ -91,16 +98,47 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut images: &images, clip: ui.clip_rect(), }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); + if let Some(o) = ptr.pressed() { + // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it + // (`object_ui::modified_hit`): the page's objects are read for it alone. + let adds = tool == QuickTool::EditText + && can_modify + && xf.rect.contains(o) + && crate::object_ui::adds(ptr.modifiers) + && !crate::object_ui::menu_open(ui, resp); + 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 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); + } + } + // A drag that the press's owner declines is nobody's once it starts. + if ptr.drag_started { + view.router.drag_began(); } // What this page has to do with the press: its owner's, the active tool's, or another page's. let press = view.router.press_for(i); // What lies under the pointer before the tools act, on the frames that need it: for a click's - // release, a right-click's tool checks, and Fill & Sign's preview. - let wants_under = 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)).unwrap_or_default(); + // release, a right-click's tool checks, Fill & Sign's preview, and Edit text's hover outlines. + let editing = matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify; + 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 its press was on: 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() => { + view.router.under_objects().map_or(PagePress::Free, PagePress::Mine) + } + press => press, + }; let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -110,12 +148,27 @@ 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 context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); + // 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. + if right.is_some() && tool == QuickTool::EditText && can_modify && !press_held { + let picture = match context { + Some(Owner::Picture { index, .. }) => Some(index), + _ => None, + }; + crate::edit_text_ui::picture_context(i, picture, view); + } // A mark already placed takes its own press (comments, below), as does one being moved on this // page until its move ends; with no button down the Fill & Sign tool's preview hides where a // press would be taken (a mark, its handles, a Prepare field). let over_mark = matches!(tool, QuickTool::Fill(_)) - && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && hovered.is_some()); tool_input(ui, view, frame, slot, &pcx, press, over_mark); let on_field = if preparing && !placing { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -132,61 +185,45 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let text = view.page_text(i); let over_text = |p: Pos2| { let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + text.as_ref().is_some_and(|t| t.glyph_at(vx, vy).is_some()) }; crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) - })); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; + // Edit text's group: the objects selected together, added content, the paragraphs and the + // pictures, each told what the press is to this page. Edit text ignores input while a dialog, + // the palette, another window or another tab has it (`gate`), as before. + let gate = !(tool == QuickTool::EditText && view.objects.input_blocked()); + if tool == QuickTool::EditText && can_modify && gate { + crate::edit_text_ui::picture_keys(ui.ctx(), view, i, &images, ptr.any_down); } - let on_content = !on_objects && !placing && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - cx, - added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - text_style, - view, - ) - || tool == QuickTool::AddText && !over_source + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, view); + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let ccx = crate::content_ui::ContentCx { + added, + press, + gate, + picks_up, + adding_text: tool == QuickTool::AddText, + edit_on_click: matches!(tool, QuickTool::AddText | QuickTool::EditText), + style: text_style, }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; + let on_content = !placing && editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view); + // Add text acts on the page's own paragraphs: the one its press was on, until the button is up, + // and the one under the pointer. Edit text boxes every paragraph and picture. + let paragraph = |o: Option| matches!(o, Some(Owner::Paragraph { .. })); + let source = tool == QuickTool::AddText && (paragraph(press.owner()) || paragraph(hovered.as_ref().map(|h| h.owner))); + let on_edit_text = (tool == QuickTool::EditText || source) && can_modify && { + let adding = (tool == QuickTool::AddText).then_some(added.as_slice()); + let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding }; + let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view); + let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); - !others_own_pointer && (text_used || image_used) + text_used || image_used }; if tool == QuickTool::EditText { - if others_own_pointer { + // A press the objects or added content took leaves the picture unselected; so does one + // while Edit text's input is blocked. Otherwise added content follows the picture selected. + if on_objects || on_content { if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { view.image_selection = None; } @@ -204,20 +241,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that // leaves them unselected. A press nobody holds that another tool took this frame (Prepare's - // selection rectangle, added content, which still decide for themselves) is not also a - // commenting tool's, as before. + // selection rectangle, added content) is not also a commenting tool's, as before. let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field; let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); - PageInput { consumed, area_tool, hover, released_over } + PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over } } /// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, /// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor /// and tip too, after the tools claimed theirs (the last claim on a layer stands). fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { - let under = hits.at(view, p); + let under = hits.at(view, p, None); if claim { for h in &under { view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); @@ -334,7 +370,8 @@ fn tool_input( /// A right-click at `p`: the page's menu opens there, and it goes to what lies on top there /// ([`PageHits::context_at`]), which the menu then acts on. A comment there is selected, and none -/// elsewhere; a Prepare field is selected; links take theirs from what this returns. +/// elsewhere; a Prepare field is selected; links and Edit text's pictures take theirs from what this +/// returns. /// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the /// comment and Prepare selections its press acts on are left alone until its release; links still /// take theirs, as before. @@ -342,16 +379,16 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu let context = hits.context_at(view, p, under); // Delete page, Paste and Copy Image act where the menu opened, whoever takes the right-click. comments::anchor(hits.pcx, p, view); + // The menu is what the right-click went to (Add text's is the page's), when that is selected: + // under a held press, the selections stay as they are. + view.menu_for = context.filter(|_| !(hits.tool == QuickTool::AddText && hits.can_modify)).map(|o| (hits.page, o)); if press_held { return context; } - // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures (the objects selected together, and crop mode's picture, among them), - // the Link tool those on a link, and a Prepare field tool the whole page, as before: comments - // see none of them. + // Add text takes every right-click on a page it can change, the Link tool those on a link, and a + // Prepare field tool the whole page, as before: comments see none of them. let tool_takes_it = match hits.tool { QuickTool::AddText => hits.can_modify, - QuickTool::EditText => under.iter().any(|h| matches!(h.owner, Owner::Paragraph { .. } | Owner::Picture { .. } | Owner::Objects { .. })), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; @@ -413,14 +450,26 @@ struct PageHits<'a> { } impl PageHits<'_> { - /// The hits at `p`, bottom up ([`Self::hits`]). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - self.hits(view, p, false) + /// Edit text's crop mode is on for a picture on this page. + fn cropping(&self, view: &DocView) -> bool { + self.tool == QuickTool::EditText && self.can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == self.page && s.crop.is_some()) + } + + /// The hits at `p`, bottom up ([`Self::hits`]). For a press with Shift or ⌘ in Edit text, the + /// page's `objects` ([`crate::object_ui::ObjectSelection::inventory`]), which take it whole. + fn at(&self, view: &DocView, p: Pos2, objects: Option<&Result, String>>) -> Vec { + self.hits(view, p, false, objects) } /// What a right-click at `p` is on: what lies on top there, as for a press, but with every - /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight - /// over a link is the highlight's ([`comments::context_hit`]). + /// tool reaching comments, as a comment's menu shows whatever the tool, and a Prepare field + /// whatever the field tool. Links count for the tools that reach them (Select; the Link tool, + /// below). Edit text's paragraphs have no menu, and give way to the picture under them (the + /// user's ruling): with Edit text, a picture under a link or a paragraph takes the right-click. + /// Nor have added content or an open paragraph's frame: those give way to a picture under them, + /// and to nothing else. (Where objects are selected together, the page view unselects the picture + /// a right-click selects, and the menu is the page's.) + /// A highlight over a link is the highlight's ([`comments::context_hit`]). /// The Link tool edits links and reaches no comment: on a document it can change, its /// right-click is what its press there would take (`under`, the hits at `p`), when that is /// anything. @@ -431,17 +480,36 @@ impl PageHits<'_> { { return Some(press.owner); } - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + let reached: Vec<_> = self + .hits(view, p, true, None) + .into_iter() + .filter(|h| (h.layer != Layer::Link || h.layer.reached_by(self.tool)) && !matches!(h.owner, Owner::Paragraph { .. })) + .collect(); + let on_top = top(reached.iter().cloned(), QuickTool::Select)?.owner; + if self.tool == QuickTool::EditText && matches!(on_top, Owner::Added { .. } | Owner::Objects { part: crate::page_router::ObjectsPart::Frame }) + { + let pictures = reached.into_iter().filter(|h| matches!(h.owner, Owner::Picture { .. })); + return Some(top(pictures, QuickTool::Select).map_or(on_top, |h| h.owner)); + } + Some(on_top) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text /// on top of artwork takes the press), added content, the objects selected together and the /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content - /// and the comments that move). In crop mode, only the picture being cropped. + /// and the comments that move). For a press with Shift or ⌘ in Edit text, only the objects + /// (`objects`, the page's); in crop mode, only the picture being cropped, which a right-click + /// reaches only on the crop. /// For a right-click (`context`), comments by [`comments::context_hit`], and no camera corner (it /// has no menu). - fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { + fn hits( + &self, + view: &DocView, + p: Pos2, + context: bool, + objects: Option<&Result, String>>, + ) -> Vec { let PageHits { page, xf, @@ -461,13 +529,24 @@ 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]; + // A press with Shift or ⌘ is the objects', crop mode's page included (`object_ui::modified_hit`). + if let Some(objects) = objects { + return crate::object_ui::modified_hit(view, xf, info, page, objects, clip, p).into_iter().collect(); } + // Crop mode holds every press on its page (`edit_text_ui::crop_hit`). A right-click is the + // crop's only on the picture being cropped, its box or its grips (#5835), where it keeps crop + // mode and opens no menu; elsewhere it goes to what lies there, as without crop mode. + if self.cropping(view) { + use crate::page_router::{CropPart, PicturePart}; + let crop = crate::edit_text_ui::crop_hit(xf, page, info, images, view, p); + let on_crop = crop.as_ref().is_some_and(|h| !matches!(h.owner, Owner::Picture { part: PicturePart::Crop(CropPart::Page), .. })); + if !context || on_crop { + return crop.into_iter().collect(); + } + } + // A right-click reaches a Prepare field whatever the field tool, and while a table is set up, + // on a document that can be changed. + let (field_tool, prepare_allowed) = if context { (None, can_modify) } else { (field_tool, prepare_allowed) }; let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { @@ -484,9 +563,9 @@ impl PageHits<'_> { .filter(|e| !e.has_changes()) .and_then(|e| lines.get(e.block)) .map(|l| xf.user_rect(info, page, l.rect.map(|v| v as f32)).expand(2.0)); - // Add text acts on the page's paragraphs only in their boxes, edges included (it - // finishes the box being typed there, `over_source`): not on pictures, nor beside a - // paragraph, where a click starts a new box. + // Add text acts on the page's paragraphs only in their boxes, edges included (its click + // there finishes the box being typed and opens the paragraph, `ParagraphCx::adding`): + // not on pictures, nor beside a paragraph, where a click starts a new box. let edit_text = tool == QuickTool::EditText; let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); ( @@ -511,7 +590,7 @@ impl PageHits<'_> { /// Text layer: find matches, selection, I-beam and drag-to-select. pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageEnv { selects_text, tool, .. } = frame.env; let PageSlot { i, r, xf } = *slot; let PageFrame { resp, ptr, pointer, .. } = *frame; if let Some(text) = view.texts.get(&i).cloned() { @@ -520,7 +599,11 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page && !input.consumed && let Some(p) = pointer.filter(|p| r.contains(*p)) { - text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p }); + // The I-beam drops where a press would go to a link (ruling E: the tools that select a + // link's text show it there). + let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. })); + let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag }; + text_pointer_input(ui, view, &text, pointer); } } } @@ -565,11 +648,14 @@ struct TextPointer<'a> { page: usize, xf: PageXform, rect: Rect, - info: &'a DocInfo, tool: QuickTool, resp: &'a egui::Response, ptr: PagePointer, pointer: Pos2, + /// A press under the pointer would go to a link. + over_link: bool, + /// The press's drag is the page's text's ([`PageInput::text_drag`]). + text_drag: bool, } struct TextGesture<'a> { @@ -584,16 +670,16 @@ struct TextGesture<'a> { vy: f32, over_text: bool, over_link: bool, + text_drag: bool, column: bool, } fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { - let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer; + let TextPointer { page, xf, rect, tool, resp, ptr, pointer: screen_pointer, over_link, text_drag } = pointer; let (vx, vy) = xf.screen_to_view(screen_pointer); - let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); - let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let over_text = text.glyph_at(vx, vy).is_some(); let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); - let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, text_drag, column }; update_text_cursor(view, &gesture); update_selection_on_press(ui, view, text, &gesture); update_dragged_selection(view, text, &gesture); @@ -613,7 +699,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, // A press held by what keeps its drag (a link, a comment that moves) selects no text; one on // text markup lets its drag select the text underneath. let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); - if gesture.ptr.pressed().is_some() && press_here && !view.router.keeps_drag() { + if gesture.ptr.pressed().is_some() && press_here && gesture.text_drag { let now = ui.input(|input| input.time); let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); @@ -628,7 +714,7 @@ fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, view.selection = Some(Selection::run(gesture.page, first, last)); } } - if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { + if gesture.ptr.drag_started && press_here && gesture.text_drag { view.actions_dismissed = None; view.selection_drag = true; let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); @@ -649,7 +735,7 @@ fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { let clicks = selection_click_count(view.clicks, gesture.tool); - if gesture.resp.dragged() + if gesture.resp.dragged_by(egui::PointerButton::Primary) && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) { match &mut selection.span { @@ -700,7 +786,7 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) { view.selection = Some(Selection::run(gesture.page, first, last)); - } else if gesture.resp.clicked() && view.clicks < 2 && !view.router.keeps_drag() { + } else if gesture.resp.clicked() && view.clicks < 2 && gesture.text_drag { let shift = ui.input(|input| input.modifiers.shift); match ( view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), @@ -740,8 +826,13 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let painter = ui.painter(); 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 }; - if tool == QuickTool::Select && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + // A press nobody holds picks the picture under it: a link's click follows the link only (ruling + // 5), though its drag or double-click still lets the picked picture go, as does a drag the page + // it is over first sees, whichever page it began on. + let pick_sees = + matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) || input.press == PagePress::Elsewhere && ptr.drag_started; + if tool == QuickTool::Select && pick_sees && !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); @@ -797,7 +888,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(Owner::Link { index }) = view.router.owner(i) + && let PagePress::Mine(Owner::Link { index }) = input.press && input.released_over && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 38b028de24..f3a1b13647 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -11,7 +11,7 @@ //! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: //! the reading also takes whether one is open. -use egui::{Event, PointerButton, Pos2, Rect}; +use egui::{Event, Modifiers, PointerButton, Pos2, Rect}; /// A primary-button press or release, and where it happened. #[derive(Clone, Copy, Debug, PartialEq)] @@ -46,13 +46,19 @@ pub(crate) struct PagePointer { pub primary_down: bool, /// Some button is down, wherever it was pressed. pub any_down: bool, + /// The modifier keys held at this frame's first primary press, or else now: a press with Shift + /// or ⌘ adds to a selection. + pub modifiers: Modifiers, + /// A primary press the pages hold turned into a drag this frame. egui's own `drag_started()` + /// counts every button, so a right-button drag would start what a left-button drag does. + pub drag_started: bool, } impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`, while /// a dialog is open over the window or not (`modal`). pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response, modal: bool) -> Self { - let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + let (at, origin, edges, released, primary_down, any_down, modifiers) = ui.input(|i| { ( i.pointer.interact_pos(), i.pointer.press_origin(), @@ -60,6 +66,7 @@ impl PagePointer { last_release(&i.events), i.pointer.primary_down(), i.pointer.any_down(), + press_modifiers(&i.events).unwrap_or(i.modifiers), ) }); // egui's own answer to "do the pages hold a press?": from the press on until the frame @@ -75,9 +82,10 @@ impl PagePointer { resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p)))); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down, modifiers, drag_started: false }; } - Self { pos, origin: press_origin, edges, released, primary_down, any_down } + let drag_started = resp.drag_started_by(PointerButton::Primary); + Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers, drag_started } } /// Where the pointer is, when it is inside `r`. @@ -108,6 +116,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// The modifier keys held at the first primary press among `events`. +fn press_modifiers(events: &[Event]) -> Option { + events.iter().find_map(|e| match e { + Event::PointerButton { button: PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), + _ => None, + }) +} + /// Where the last primary release among `events` was, however many came before it. fn last_release(events: &[Event]) -> Option { events.iter().rev().find_map(|e| match e { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 3f5d1e105f..21698e7f95 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -207,7 +207,7 @@ const PAGE_MODULES: [&str; 37] = [ /// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, /// what a press is to a page, the router's hits and owners, or the router itself. Such a file is a /// page module. One that acts on the pages through the page view's response and a bare point alone -/// uses none of them (`canvas/image_pick.rs`): PAGE_MODULES lists it by hand. +/// would use none of them, and PAGE_MODULES would list it by hand. const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."]; /// The router's own files, which define what page modules use. @@ -260,7 +260,11 @@ fn declares(line: &str) -> Option<&str> { let mut rest = line; loop { let before = rest; - for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] { + // `pub(crate)`, `pub(super)`, `pub(in crate::canvas)` and the like. + if let Some(scoped) = rest.strip_prefix("pub(") { + rest = scoped.split_once(") ").map_or(rest, |(_, after)| after); + } + for prefix in ["pub ", "const ", "async ", "unsafe "] { rest = rest.strip_prefix(prefix).unwrap_or(rest); } if rest == before { @@ -271,6 +275,19 @@ fn declares(line: &str) -> Option<&str> { rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) } +/// egui's drag start, which counts every button: a right-button drag would start what a left one +/// does. Page modules read `PagePointer::drag_started`, the primary button's, or ask for that button +/// (`drag_started_by`). +const ANY_BUTTON_DRAG: [&str; 1] = [".drag_started()"]; + +/// Functions in the page modules that start a drag of a widget of their own, not of the pages: +/// (file, function, why). +const NOT_PAGE_DRAGS: [(&str, &str, &str); 3] = [ + ("canvas/organize.rs", "drag_pages", "Organize Pages drags thumbnails, not pages"), + ("tools/fill_sign.rs", "signature_pad", "the signature dialog's drawing pad"), + ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), +]; + /// Ways to set egui's cursor. const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("]; @@ -462,6 +479,14 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { } } } + problems.extend(misuses( + &ANY_BUTTON_DRAG, + &NOT_PAGE_DRAGS, + "starts a drag with any button: a right-button drag would start what a left one does. Read \ + `PagePointer::drag_started` (the primary button's) instead. A widget of its own, not the pages? Add the \ + function to NOT_PAGE_DRAGS in src/canvas/page_pointer/tests.rs, with why.", + "starts no drag of its own any more: remove it from NOT_PAGE_DRAGS.", + )); assert!(problems.is_empty(), "{}", problems.join("\n")); } @@ -483,20 +508,22 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 8] = [ +const ROUTER_WRITES: [&str; 10] = [ "router.begin(", "router.press(", + "router.drag_began(", "router.settle(", "router.cancel(", "PageRouter::begin(", "PageRouter::press(", + "PageRouter::drag_began(", "PageRouter::settle(", "PageRouter::cancel(", ]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ - ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press, and a declined drag lets it go"), ("canvas.rs", "draw_pages", "the press is over once the button is up and its release seen, and a new one drops the last owner"), ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"), ]; @@ -523,11 +550,12 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Hit tests are found by name (a /// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test -/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's, -/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and -/// most are PR 4b's. `PageHits::hits` is the one: the hits -/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover -/// painting and a right-click. +/// has a list of its own (`COMMENT_POINT_TESTERS`). Point tests by other names are not seen: what is +/// left of them paints (a picture's hover outline), keeps a behaviour no hit stands for (added +/// text's click with the tools that don't pick it up, crop mode's controls) or tells a tool that a +/// press nobody holds began on its page (the text layer's `press_here`, redaction's). `PageHits::hits` +/// is the one: the hits at the press point decide its owner, and those under the pointer give its +/// cursor, its tip, hover painting and a right-click. const HIT_TESTERS: [(&str, &str, &str); 1] = [( "canvas/page_input.rs", "hits", @@ -566,27 +594,38 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ ]; /// What reads who holds the press the pages hold. -const ROUTER_READS: [&str; 6] = - ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "PageRouter::keeps_drag("]; +const ROUTER_READS: [&str; 4] = ["router.press_for(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::keeps_drag("]; /// The functions that read who holds the press: (file, function, why). The page view asks once per -/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the -/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry -/// fails the test. -const ROUTER_READERS: [(&str, &str, &str); 8] = [ - ( - "canvas/page_input.rs", - "page_tool_input", - "the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b", - ), - ("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"), - ("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"), - ("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"), - ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), - ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), - ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), - ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), -]; +/// page and tells each module what the press is to its page (`PagePress`): modules never read the +/// router, and outside `canvas` the compiler refuses them (its methods are `pub(in crate::canvas)`). +/// An unused entry fails the test. +const ROUTER_READERS: [(&str, &str, &str); 1] = [( + "canvas/page_input.rs", + "page_tool_input", + "the page view: what the press is to each page (`press_for`), and whether its drag is the text's (`keeps_drag`)", +)]; + +/// Words a page module's functions are not named with: a function that grabs or owns a press decides +/// for itself whether the press is its own, which the page view tells it (`PagePress`). +const PRESS_CLAIMING_WORDS: [&str; 2] = ["grabs", "owns"]; + +/// The function `line` declares, when one of the words of its name is one of +/// [`PRESS_CLAIMING_WORDS`]. +fn press_claiming_fn(line: &str) -> Vec { + declares(line.trim_start()) + .filter(|name| name.split('_').any(|word| PRESS_CLAIMING_WORDS.contains(&word))) + .map(str::to_owned) + .into_iter() + .collect() +} + +/// Whether `line` takes the router whole (`let r = &view.router;`), which the checks by method +/// name would not see through: anything but a method call on it. +fn takes_the_router(line: &str) -> bool { + line.match_indices(".router") + .any(|(at, name)| line[at + name.len()..].chars().next().is_none_or(|c| c != '.' && !(c.is_alphanumeric() || c == '_'))) +} /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -595,34 +634,6 @@ const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; const PRESS_IGNORERS: [(&str, &str, &str); 1] = [("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; -/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who -/// may). -const PAGE_HIT_CALLS: [&str; 12] = [ - "link_ui::hit(", - "prepare::hit(", - "forms_ui::hit(", - "comments::hit(", - "comments::context_hit(", - "camera_ui::hit(", - "edit_text_ui::hit(", - "edit_text_ui::picture_hit(", - "edit_text_ui::picture_handle_hit(", - "edit_text_ui::crop_hit(", - "object_ui::hit(", - "content_ui::hit(", -]; - -/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test -/// of its own to choose one. -fn routed_press_handler(code: &str) -> bool { - let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); - code.contains("router.owner(") - && !PAGE_HIT_CALLS.iter().any(|call| code.contains(call)) - && !uses(&code, "hit(") - && !uses(&code, "body_hit(") - && !uses(&code, "handle_hit(") -} - /// Page modules act on a press only when the router says it is theirs. The hits at the press /// point decide its owner once, in one place, and a `Hit` comes only from a module's hit test, /// so what the cursor promised is what the press does. Where each module tested the press @@ -653,7 +664,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { // functions use it. let known: Vec<&str> = ROUTER_WRITES.iter().chain(ROUTER_READS.iter()).copied().collect(); let unlisted = page_module_matches(|line| { - let router = uses(line, ".router.") || uses(line, "PageRouter::"); + let router = uses(line, ".router.") || uses(line, "PageRouter::") || takes_the_router(line); if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() } }); problems.extend( @@ -687,6 +698,13 @@ fn page_modules_act_on_a_press_only_through_the_router() { place that must ask? Add the function to PAGE_HITS_ASKERS in src/canvas/page_pointer/tests.rs, with why.", "no longer asks the page view's hits: remove it from PAGE_HITS_ASKERS.", )); + // A module is told what the press is to its page; none decides it for itself. + problems.extend(page_module_matches(press_claiming_fn).into_iter().map(|(file, _, n, name)| { + format!( + "src/{file}:{n}: `fn {name}` decides whether a press is its module's. The page view tells each module what the \ + press is to its page (`PagePress`): act on that, in a function named for what it does." + ) + })); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, @@ -694,19 +712,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { `DocView::cancel_pointer_gestures` instead.", "no longer ignores a press: remove it from PRESS_IGNORERS.", )); - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - for (file, function) in [ - ("tools/content_ui.rs", "page_input"), - ("tools/edit_text_ui.rs", "page_input"), - ("tools/edit_text_ui/image.rs", "image_input"), - ("tools/object_ui.rs", "input"), - ] { - let source = std::fs::read_to_string(src.join(file)).expect("page input module"); - let body = source.lines().skip_while(|line| declares(line) != Some(function)).take_while(|line| *line != "}").collect::>().join("\n"); - if !routed_press_handler(&body) { - problems.push(format!("src/{file}: {function} must consume the captured router owner without repeating a hit test")); - } - } // A hit test is a function named `hit` or `…_hit`: one answer to "what of mine is here?", // which gives the module's cursor, its tip and its press. problems.extend(misuses_in( @@ -720,14 +725,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { "calls no hit test any more: remove it from HIT_TESTERS.", )); problems.extend(hit_test_aliases()); - // An entry no page module calls any more no longer guards anything. - let called = page_module_uses(&PAGE_HIT_CALLS); - problems.extend( - PAGE_HIT_CALLS - .iter() - .filter(|call| !called.iter().any(|(_, _, _, name)| name == *call)) - .map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")), - ); let builds = page_module_uses(&HIT_BUILDS); assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { @@ -758,6 +755,15 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { assert!(hit_test_calls("let all = self.hits(view, p, false);").is_empty()); assert_eq!(declares("pub(crate) const fn crop_hit<'a>(view: &DocView) {"), Some("crop_hit")); assert_eq!(declares("let hit = 1;"), None); + assert_eq!(press_claiming_fn(" pub(crate) fn fill_grabs(view: &DocView) -> bool {"), ["fill_grabs"]); + assert_eq!(press_claiming_fn("fn owns_press() {"), ["owns_press"]); + assert_eq!(press_claiming_fn(" pub(in crate::canvas) fn link_grabs(view: &DocView) {"), ["link_grabs"]); + assert!(press_claiming_fn("fn dropdowns() {").is_empty(), "a word that ends in one"); + assert_eq!(declares("pub(in crate::canvas) fn drag_began(&mut self) {"), Some("drag_began")); + assert!(takes_the_router("let r = &view.router;") && takes_the_router("keep(view.router, 1)")); + assert!(!takes_the_router("view.router.press_for(i)") && !takes_the_router("view.routers.len()")); + assert!(press_claiming_fn("fn holds(&self, page: usize) -> bool {").is_empty()); + assert!(press_claiming_fn("let grabs = 1;").is_empty()); let source = "impl PageHits<'_> {\n fn at(&self) {\n self\n .hits(view, p, false)\n }\n}\nfn after() {\n // view.router.owner(0)\n let _ = view\n .router\n .owner(0);\n}\n"; assert_eq!( code_lines(source), @@ -777,13 +783,3 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { let aliases: Vec<(String, String)> = [("hit", "corner_under"), ("picture_hit", "under")].map(|(a, b)| (a.to_owned(), b.to_owned())).to_vec(); assert_eq!(aliased_hit_tests(source), aliases); } - -#[test] -fn the_page_press_guard_rejects_missing_owners_and_second_hit_tests() { - assert!(routed_press_handler("let owner = view.router.owner(page);")); - assert!(!routed_press_handler("// view.router.owner(page)\nlet owner = None;")); - assert!(!routed_press_handler("let owner = view.router.owner(page);\nlet again = cx.hit(origin);")); - for call in PAGE_HIT_CALLS { - assert!(!routed_press_handler(&format!("let owner = view.router.owner(page);\n{call}origin);")), "{call}"); - } -} diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index e3b88d1a2c..30a3f2126f 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,10 +6,13 @@ //! press point decide who captures it ([`PageRouter::press`]), by the same table: the highest layer //! the active tool reaches. Until the button is up only the owner acts on the press, its drag and //! its release, so what the cursor promised is what the press does, and nothing takes a gesture -//! back halfway. Each page is told what it has to do with the press ([`PagePress`]): its owner's, -//! the active tool's, or none of its business. +//! back halfway; an owner that declines the drag (text markup, a link with the Link tool) lets it +//! go as it starts, and the drag is nobody's. Each page is told what it has to do with the press +//! ([`PagePress`]): its owner's, the active tool's, or none of its business. Only the page view +//! reads the router: its methods are `canvas`'s alone. use egui::CursorIcon; +use pdfcraft_engine::ObjectTarget; use crate::QuickTool; use crate::page_cursor::Layer; @@ -45,10 +48,31 @@ pub(crate) enum Owner { pub(crate) enum PicturePart { /// The picture: a click selects it, a drag moves the selected one. Body, + /// A picture lying over the selected one, within the selected one's frame: a click selects the + /// one on top, a drag moves the selected one. + OverSelected, /// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints). 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 picture outside the box kept, shown or cut away: a press there moves nothing, and a + /// right-click is the crop's, as on the box and its grips (#5835). + Cut, + /// 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. @@ -58,6 +82,15 @@ pub(crate) enum ObjectsPart { Selection, /// The frame drawn around the paragraph open for typing: a click selects the box, a drag moves it. Frame, + /// A press with Shift or ⌘ (Ctrl): it adds the object under it to the objects selected + /// together, or takes it out of them, or on none starts an area selection that adds to them. + Toggle(Option), + /// A press with Shift or ⌘ while text being typed is open (a new box, or a paragraph with + /// changes), which must be finished or discarded first: it is held until the button is up, and + /// selects nothing. + Held, + /// A press with Shift or ⌘ on a page whose objects can't be selected together: it says so. + Refused, } /// The part of a paragraph a hit is on. @@ -102,7 +135,7 @@ impl Hit { /// The hit that wins among `hits` with `tool`: the highest layer the tool reaches, the last of /// equals (modules are asked bottom up, in paint order). -pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { +pub(in crate::canvas) fn top(hits: impl IntoIterator, tool: QuickTool) -> Option { hits.into_iter().filter(|h| h.layer.reached_by(tool)).fold(None, |best: Option, h| match best { Some(b) if b.layer > h.layer => Some(b), _ => Some(h), @@ -115,13 +148,15 @@ pub(crate) enum PagePress { /// The press began on this page, and the owner's hit won it: its module acts on it. Others /// only let go of their own state (a click on anything but the selected comment unselects it). Mine(Owner), - /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. Also every page's while a press the page view does not hold is down (one that began + /// No button is down, or the press began on this page and nobody's hit won it, or its owner let + /// its drag go ([`PageRouter::drag_began`]): the active tool's. Also every page's while a + /// press the page view does not hold is down (one that began /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is /// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a /// cancelled press either way). Free, - /// The press began on another page, or off the pages (between them, or beside one). + /// The press began on another page, or off the pages (between them, or beside one) where no + /// page has anything under it. Elsewhere, } @@ -137,9 +172,12 @@ impl PagePress { /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] -pub(crate) struct Capture { +struct Capture { pub owner: Owner, pub declines_drag: bool, + /// What lay on top at the press with the objects selected together aside: a double-click on + /// them reaches it. + under_objects: Option, } /// The press the pages hold, from its frame until the button is up. @@ -153,25 +191,28 @@ pub(crate) struct PageRouter { impl PageRouter { /// A new press on the pages: the last one's owner goes, and until a page claims it the press is /// between the pages. - pub(crate) fn begin(&mut self) { + pub(in crate::canvas) fn begin(&mut self) { self.capture = None; self.began = Some(None); } - /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's - /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + /// A press on `page`, or past its edge where `hits` are under it (the selected picture's rotate + /// knob): `hits` at the press point decide who it belongs to. A press nobody's hit claims is held + /// by nobody (the page's text, or a tool that acts anywhere on the page). /// Only a press just begun ([`Self::begin`]) is claimed, by the first page that does: pages /// that touch (the gaps hidden) can share a row of pixels, and a press there is one page's. - pub(crate) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { + pub(in crate::canvas) fn press(&mut self, page: usize, hits: impl IntoIterator, tool: QuickTool) { if self.began != Some(None) { return; } - self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); + let hits: Vec = hits.into_iter().collect(); + let under_objects = top(hits.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool).map(|h| h.owner); + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag, under_objects }); self.began = Some(Some(page)); } /// What `page` has to do with the press the pages hold. - pub(crate) fn press_for(&self, page: usize) -> PagePress { + pub(in crate::canvas) fn press_for(&self, page: usize) -> PagePress { match (self.began, &self.capture) { (None, _) => PagePress::Free, (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), @@ -180,19 +221,28 @@ impl PageRouter { } } - /// Who holds the press on `page`. - pub(crate) fn owner(&self, page: usize) -> Option { - self.press_for(page).owner() + /// What lay on top at the press with the objects selected together aside. + pub(in crate::canvas) fn under_objects(&self) -> Option { + self.capture.as_ref().and_then(|c| c.under_objects) } /// Whether the press is held by someone who keeps its drag: then the page's text is not /// selected by it. - pub(crate) fn keeps_drag(&self) -> bool { + pub(in crate::canvas) fn keeps_drag(&self) -> bool { self.capture.as_ref().is_some_and(|c| !c.declines_drag) } + /// The press turned into a drag: an owner that declines its drag lets it go, and from then on + /// the press is nobody's (a drag across a highlight selects the text under it; with the Link + /// tool, a drag from a link draws a new one). Its click stayed the owner's. + pub(in crate::canvas) fn drag_began(&mut self) { + if self.capture.as_ref().is_some_and(|c| c.declines_drag) { + self.capture = None; + } + } + /// The press is over once the button is up and its release has been seen. - pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { + pub(in crate::canvas) fn settle(&mut self, any_down: bool, edges: bool) { if !any_down && !edges { self.capture = None; self.began = None; @@ -201,7 +251,7 @@ impl PageRouter { /// Drops the press (Escape, automation's cancel): every page is the active tool's again /// ([`PagePress::Free`]). Page input skips a press Escape cancelled until the button is up. - pub(crate) fn cancel(&mut self) { + pub(in crate::canvas) fn cancel(&mut self) { self.capture = None; self.began = None; } @@ -230,16 +280,16 @@ mod tests { let mut r = PageRouter::default(); r.begin(); r.press(2, [hit(Layer::Comment, 7)], QuickTool::Select); - assert_eq!(r.owner(2), Some(hit(Layer::Comment, 7).owner)); - assert_eq!(r.owner(1), None, "only on its page"); + assert_eq!(r.press_for(2).owner(), Some(hit(Layer::Comment, 7).owner)); + assert_eq!(r.press_for(1).owner(), None, "only on its page"); assert!(r.keeps_drag()); r.settle(false, true); - assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); + assert!(r.press_for(2).owner().is_some(), "the release frame still belongs to the press"); r.settle(false, false); - assert_eq!(r.owner(2), None); + assert_eq!(r.press_for(2).owner(), None); r.begin(); r.press(0, [], QuickTool::Select); - assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); + assert_eq!(r.press_for(0).owner(), None, "a press nobody claims is held by nobody"); assert!(!r.keeps_drag()); } @@ -281,8 +331,23 @@ mod tests { let mut r = PageRouter::default(); r.begin(); r.press(0, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); - assert!(r.owner(0).is_some() && !r.keeps_drag()); + assert!(r.press_for(0).owner().is_some() && !r.keeps_drag()); r.cancel(); - assert_eq!(r.owner(0), None); + assert_eq!(r.press_for(0).owner(), None); + } + + /// A drag the owner declines is nobody's once it starts, on the page it began on; a drag the + /// owner keeps stays its own. + #[test] + fn a_declined_drag_is_nobodys_once_it_starts() { + let mut r = PageRouter::default(); + r.begin(); + r.press(1, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); + r.drag_began(); + assert_eq!((r.press_for(1), r.press_for(0)), (PagePress::Free, PagePress::Elsewhere)); + r.begin(); + r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select); + r.drag_began(); + assert_eq!(r.press_for(1), PagePress::Mine(hit(Layer::Comment, 2).owner)); } } diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index 817d77bb9f..8da5351ee0 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -102,14 +102,14 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe // began, a click where it came up (egui has cleared the press's origin by its release, and the // pointer may have moved on in the same frame). let at = if resp.clicked() { ptr.released } else { origin }; - if (resp.drag_started() || resp.clicked()) + if (ptr.drag_started || resp.clicked()) && press == PagePress::Free && at.is_some_and(|p| xf.rect.contains(p)) && view.camera.is_none_or(|q| q.page != page) { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() + if ptr.drag_started && let PagePress::Mine(Owner::CameraCorner { corner }) = press && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index fa56f7a231..8e21f1219a 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -12,8 +12,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// the press the pages hold: a press on a comment where the tool reaches it ([`hit`]: the Select /// tool, Fill & Sign over its marks, a shape tool over the selected shape's handles) is that /// comment's, and one nobody holds is a commenting tool's. When another tool took this frame -/// (`taken`: Prepare, added content, which still decide for themselves), the commenting tools leave -/// it to that tool, as before; the Select tool's click there still unselects. Returns `true` when +/// (`taken`: Prepare, added content), the commenting tools leave it to that tool, as before; the +/// Select tool's click there still unselects. Returns `true` when /// the pointer gesture belongs to commenting (text selection must ignore it). pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, press: PagePress, taken: bool, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); @@ -227,7 +227,7 @@ fn select_input( }; let cv = &mut view.comments; let mut consumed = false; - if resp.drag_started() + if cx.ptr.drag_started && let (Some((a, part)), Some(o)) = (owner, origin) { match part { @@ -272,9 +272,17 @@ fn select_input( CommentPart::Body => {} } } - if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { + let live = + matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page); + if live { consumed = true; } + // A move, resize or reshape ends where its drag stops. One whose release the pages missed (the + // button is up, and no drag stopped here) ends with nothing applied: where it came up is not + // known, and the pointer may be anywhere by now. + if live && !resp.drag_stopped() && !cx.ptr.primary_down { + cv.gesture = None; + } if resp.drag_stopped() && let Some(p) = pointer { @@ -374,7 +382,8 @@ fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: /// Text asks for the replacement. pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) { let QuickTool::Comment(tool) = cx.tool else { return }; - if tool == CommentTool::ReplaceText && cx.allowed && (resp.drag_stopped() || resp.double_clicked()) { + let finished = resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked(); + if tool == CommentTool::ReplaceText && cx.allowed && finished { if let Some((page, quads)) = view.selection_quads(cx.info).filter(|(p, _)| *p == cx.page) { view.clear_selection(); let last = quads.last().copied().unwrap_or_default(); @@ -384,7 +393,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } return; } - if tool.markup().is_none() || !cx.allowed || !(resp.drag_stopped() || resp.double_clicked()) { + if tool.markup().is_none() || !cx.allowed || !finished { return; } if let Some(kind) = tool.markup() @@ -429,7 +438,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { @@ -567,7 +576,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { @@ -648,7 +657,7 @@ impl Gestures<'_> { // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its // text wraps inside it (#2669). if tool == CommentTool::TextBox { - if resp.drag_started() + if cx.ptr.drag_started && pressed_here && let Some(o) = origin { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 5b5154f90a..a5cfbd4c2e 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -11,7 +11,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; use crate::widgets; @@ -108,6 +108,10 @@ pub struct ContentView { /// Select the item an add creates (the page's item count before it). pub select_added: Option<(usize, usize)>, grab: Option<(usize, Grab, Pos2)>, + /// Where the press nobody holds began, on which page: Add text's click starts its new box there. + pressed_at: Option<(usize, Pos2)>, + /// A button is down on the pages this frame (for [`keys`], which runs after them). + held: bool, undrawable: UndrawableMemo, layout: Option<(AddedText, f32, f64, [f64; 4])>, } @@ -116,6 +120,12 @@ impl ContentView { /// Forget an in-progress pointer grab for content editing. pub(crate) fn cancel_pointer_gesture(&mut self) { self.grab = None; + self.pressed_at = None; + } + + /// An added item is being moved or resized on `page`. + pub(crate) fn moving_on(&self, page: usize) -> bool { + self.grab.is_some_and(|(p, ..)| p == page) } pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option { @@ -372,17 +382,34 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], Some(Hit::new(Owner::Added { index: *index, corner: None }, Layer::Placed, egui::CursorIcon::Move)) } -/// Pointer input on one page in Edit mode (`adding_text` with the Text tool). `true` when it -/// used the input. -pub(crate) fn page_input( - cx: PageToolContext<'_>, - added: &[Added], - adding_text: bool, - edit_on_click: bool, - style: &AddedText, - view: &mut DocView, -) -> bool { +/// What added content is told on one page. +#[derive(Clone, Copy)] +pub(crate) struct ContentCx<'a> { + pub added: &'a [Added], + /// What the press the pages hold is to this page. With the Edit panel open, a press on added + /// text is its own with the tools that pick it up ([`hit`]). + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, + /// The tool picks added content up (Select, Add text, Edit text): a drag moves it or resizes it. + pub picks_up: bool, + /// The Add text tool: a click nobody holds starts a new box where it was pressed. + pub adding_text: bool, + /// A click on added text opens it for typing (Add text, Edit text); otherwise a double-click does. + pub edit_on_click: bool, + /// The style of new text. + pub style: &'a AddedText, +} + +/// Pointer input on one page in Edit mode, on the presses that are added content's ([`hit`]) and, +/// with Add text, the clicks nobody holds. `true` when it used the input. +pub(crate) fn page_input(cx: PageToolContext<'_>, ccx: ContentCx<'_>, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let ContentCx { added, press, gate, picks_up, adding_text, edit_on_click, style } = ccx; + view.content.held = ptr.any_down; + if !gate { + return false; + } let mut consumed = false; let pointer = ptr.pos; let cv = &mut view.content; @@ -412,19 +439,28 @@ pub(crate) fn page_input( } return consumed; } + // Where a press nobody holds begins on this page: Add text's click starts its box there. + if let Some(o) = ptr.pressed().filter(|_| press != PagePress::Elsewhere) { + cv.pressed_at = (press == PagePress::Free).then_some((page, o)); + } let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return consumed }; let items: Vec<(usize, &Added)> = on_page(added, page).into_iter().filter(|(_, a)| matches!(a.content, AddedContent::Text(_))).collect(); - let hit = items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, a)| (*i, *a)); - let selected_rect = cv - .selected - .filter(|s| s.0 == page) - .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) - .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); - let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); - if adding_text && hit.is_none() { + // What the press was on ([`hit`]): an item, or a corner of the selected one. + let owned = match press { + PagePress::Mine(Owner::Added { index, corner }) => Some((index, corner)), + _ => None, + }; + // The tools that don't pick added content up have no hit on it: with the Edit panel open their + // clicks on added text still select it and their presses there are its, by this test at the + // pointer, as before. + let under = (!picks_up) + .then(|| items.iter().rev().find(|(_, a)| screen_rect(xf, info, page, a.content.rect()).expand(2.0).contains(p)).map(|(i, _)| *i)) + .flatten(); + let target = if picks_up { owned } else { under.map(|i| (i, None)) }; + if adding_text { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); // A new box where nothing drawn over the page holds the press. - if resp.clicked() && view.router.owner(page).is_none() { + if resp.clicked() && press == PagePress::Free { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { return true; @@ -434,7 +470,9 @@ pub(crate) fn page_input( let typed = cv.draft.take().and_then(|d| finish(cv, d, added)); // The Format panel styles the new text, not the box just kept. (cv.selected, cv.select_added) = (None, None); - let at = to_display(xf, info, page, p); + // Where the click was pressed, as every owner acts where its press was. + let start = cv.pressed_at.take().filter(|(pg, _)| *pg == page).map_or(p, |(_, o)| o); + let at = to_display(xf, info, page, start); let mut style = style.clone(); style.text.clear(); let page_width = info.pages.get(page).map_or(f64::INFINITY, |p| p.width as f64); @@ -445,29 +483,31 @@ pub(crate) fn page_input( if typed.is_some() { view.pending_edit = typed; } + return consumed; } - return consumed; } - // A drag on what the press was on ([`hit`]): a corner resizes the selected item, the item - // itself moves. - if resp.drag_started() { + // A drag on what the press was on: a corner resizes the selected item, the item itself moves, + // with the tools that pick added content up. + if ptr.drag_started { let origin = ptr.origin.unwrap_or(p); - match view.router.owner(page) { - Some(Owner::Added { corner: Some((left, top)), .. }) => { + match owned.filter(|_| picks_up) { + Some((_, Some((left, top)))) => { cv.grab = Some((page, Grab::Corner(left, top), origin)); consumed = true; } - Some(Owner::Added { index, corner: None }) => { + Some((index, None)) => { cv.selected = Some((page, index)); cv.grab = Some((page, Grab::Move, origin)); consumed = true; } - _ => {} + None => {} } - return consumed; + return consumed || owned.is_some(); } - if (resp.double_clicked() || edit_on_click && resp.clicked() && corner.is_none()) - && let Some((i, a)) = hit + // A double-click, or with `edit_on_click` a click, opens the text the press was on for typing. + if (resp.double_clicked() || edit_on_click && resp.clicked()) + && let Some((i, None)) = target + && let Some((_, a)) = items.iter().find(|(index, _)| *index == i) && let AddedContent::Text(t) = &a.content { if cv.hold_blocked() { @@ -493,17 +533,21 @@ pub(crate) fn page_input( consumed = true; return consumed; } + // A click selects the item its press was on, keeps the selected one when on a corner of it, and + // on anything else on this page (but the objects selected together, which take the selection + // themselves) leaves it unselected. if resp.clicked() { - match hit { - Some((i, _)) => { + match (target, press) { + (Some((i, None)), _) => { cv.selected = Some((page, i)); consumed = true; } - None if corner.is_none() => cv.selected = None, - None => consumed = true, + (Some((_, Some(_))), _) => consumed = true, + (None, PagePress::Elsewhere | PagePress::Mine(Owner::Objects { .. })) => {} + (None, _) => cv.selected = None, } } - consumed || hit.is_some() && resp.is_pointer_button_down_on() + consumed || owned.is_some() || under.is_some() && resp.is_pointer_button_down_on() } /// Outlines of added items (hover and selection with handles) and the box being dragged, the @@ -593,7 +637,11 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, image_controller_act return; } let (del, esc) = ctx.input(|i| (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace), i.key_pressed(egui::Key::Escape))); - if del && let Some((page, index)) = view.content.selected.take() { + // Not while a button is down: the press held would then name another item. + if del + && !view.content.held + && let Some((page, index)) = view.content.selected.take() + { view.pending_edit = Some(Edit::DeleteContent { page, index }); } else if esc { view.content.selected = None; diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f520a91f86..f2833de356 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -14,17 +14,17 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; -use crate::page_router::{Hit, Owner, ParagraphPart}; +use crate::page_router::{Hit, Owner, PagePress, ParagraphPart}; mod background; mod image; mod layout; pub(crate) use background::TextBackground; -pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; +pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection}; 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, + ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, picture_context, + picture_keys, quad_contains, user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; @@ -659,10 +659,26 @@ fn open_editor(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], bo }); } -/// Lines and their screen boxes for a page; hover outlines, click opens the editor. Returns -/// `true` when the pointer was used. -pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], view: &mut DocView, allow_interaction: bool) -> bool { +/// What Edit text's paragraphs are told on one page. +#[derive(Clone, Copy)] +pub(crate) struct ParagraphCx<'a> { + /// What the press the pages hold is to this page. + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, + /// What a press under the pointer would take (the top hit there): the paragraph outlined. + pub hover: Option<&'a Hit>, + /// With Add text, the page's added content: a click on a paragraph finishes the text being + /// typed before it opens the paragraph. + pub adding: Option<&'a [pdfcraft_engine::Added]>, +} + +/// Lines and their screen boxes for a page: the paragraph under the pointer is outlined, and the +/// press that is a paragraph's (`press`) moves it, rewraps it or, a click, opens it in the editor. +/// Returns `true` when the pointer was used. +pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::TextBlock], pcx: ParagraphCx<'_>, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; + let ParagraphCx { press, gate, hover, adding } = pcx; let info = &doc.info; if view.block_drag.is_some_and(|d| d.generation != doc.edit_generation()) { view.block_drag = None; @@ -679,7 +695,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text } // A box a switching click asked for opens now, once the edit queued with that click has // applied and `lines`/`boxes` are the document's new inventory (#2022). - if allow_interaction + if gate && active.is_none() && let Some((open_page, key)) = view.pending_text_open.clone() && open_page == page @@ -691,7 +707,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text None => view.pending_text_open = None, } } - if !allow_interaction { + if !gate { return false; } let upright = has_edges(info, page); @@ -750,12 +766,15 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text // What it grabs is what the press was on ([`hit`]), not what is under the pointer now: egui // only calls it a drag once the pointer has moved a few pixels (or been held a moment), and a // quick flick has left the edge by then. - let owner = match view.router.owner(page) { - Some(Owner::Paragraph { block, part }) => Some((block, part)), + let owner = match press { + PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)), _ => None, }; - if resp.drag_started() + // Add text holds text that can't be drawn where it is being typed (#125). + let held_draft = adding.is_some() && view.content.blocked().is_some(); + if ptr.drag_started && active.is_none() + && !held_draft && let Some(o) = ptr.origin && let Some((block, part)) = owner { @@ -767,18 +786,35 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } - let Some(p) = ptr.within(xf.rect) else { return false }; - // The paragraph under the pointer: outlined, with its width handles when closed. - let Some((hovered, _)) = grab_at(&boxes, p, upright) else { return false }; - if active != Some(hovered) { - painter.rect_stroke(boxes[hovered], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - } - if upright && active.is_none() { - draw_handles(boxes[hovered]); + // The paragraph under the pointer, where a press would take it: outlined, with its width + // handles when closed. Not while the objects selected together or added content being moved + // hold the page. + let hovered = match hover.map(|h| h.owner) { + Some(Owner::Paragraph { block, .. }) => Some(block), + _ => None, + }; + if let Some(b) = hovered.filter(|_| !view.objects.holds(page) && !view.content.moving_on(page)).and_then(|h| boxes.get(h).map(|b| (h, *b))) { + if active != Some(b.0) { + painter.rect_stroke(b.1, CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + } + if upright && active.is_none() { + draw_handles(b.1); + } } // A click opens the paragraph the press was on. - let Some((hit, _)) = owner else { return true }; + let Some((hit, _)) = owner else { return hovered.is_some() }; if resp.clicked() { + // Add text: text that can't be drawn stays where it is being typed (#125), and text being + // typed is finished first. + if let Some(added) = adding { + if view.content.hold_blocked() { + return true; + } + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } view.image_selection = None; // A click that moves the editor to another paragraph applies the open one first, so its // typed text is not dropped with the switched-away editor (#2022). A click on the open 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..07ca017798 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,7 +13,10 @@ pub(crate) struct ImageCx<'a> { pub ptr: &'a PagePointer, pub page: usize, pub info: &'a DocInfo, - pub allow_interaction: bool, + /// What the press the pages hold is to this page. + pub press: PagePress, + /// Edit text's input is open: no dialog, palette, other window or other tab has it. + pub gate: bool, } /// The 8 resize handles on an image (4 corners + 4 edge centers). @@ -116,14 +119,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,50 +264,31 @@ 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 -/// 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, - mode: &CropMode, - xf: &PageXform, - info: &DocInfo, - 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 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, gate } = *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); - if allow_interaction - && resp.drag_started() + // A drag takes what its press was on ([`crop_hit`]). + if gate + && ptr.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::Cut | CropPart::Page => None, + }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); } @@ -352,7 +351,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 +384,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); @@ -393,10 +393,10 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let done_resp = ui.interact(done, done_id, egui::Sense::click()); let cancel_resp = ui.interact(cancel, cancel_id, egui::Sense::click()); let reset_resp = reset.map(|r| ui.interact(r, reset_id, egui::Sense::click())); - done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Done"))); - cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Cancel"))); + done_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Done"))); + cancel_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Cancel"))); if let Some(response) = &reset_resp { - response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allow_interaction, tl!("Remove Crop"))); + response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, gate, tl!("Remove Crop"))); } let t = crate::theme::Tokens::get(ui.ctx()); @@ -430,56 +430,83 @@ 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 + let done_clicked = gate && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); + let cancel_clicked = gate && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); + let reset_clicked = gate && reset .is_some_and(|r| reset_resp.as_ref().is_some_and(|res| res.clicked()) || (resp.clicked() && hover_pos.is_some_and(|p| r.contains(p)))); 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 keys of the picture selected on `page`, a page being drawn, so the page grid and the Pages +/// panel have had theirs; nothing while the page is off the screen, as pages near it are drawn too. +/// Delete or Backspace removes it, wherever on the pages the pointer is, unless it is being cropped or +/// a button is down: the press held would then name another picture. 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, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) { + if ctx.egui_wants_keyboard_input() || view.page_screen_rect(page).is_none() { + return; + } + let Some(s) = view.image_selection.as_ref().filter(|s| s.page == page) else { return }; + let index = s.index; + let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k)); + let image = images.get(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 !held && !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() { + // The key is the picture's: added content's own Delete runs later this frame. + ctx.input_mut(|i| { + i.consume_key(egui::Modifiers::NONE, egui::Key::Delete); + i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace); + }); + view.image_selection = None; + // Added content's selection follows the picture's (`content_ui::sync_image_selection`), and + // goes with it: once the picture is gone it would be the next added item's. + view.content.selected = 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,29 +568,63 @@ 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`], 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 crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); + if mode.controls.iter().any(|r| r.contains(p)) { + return crop(CropPart::Page, egui::CursorIcon::PointingHand); + } + 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())); + } + if !xf.rect.contains(p) { + return None; + } + 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; } let (x, y) = xf.screen_to_view(p); 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))?; + let on_picture = |im: &pdfcraft_engine::PageImage| { + im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user) + }; + // The selected picture's frame is its own, a hole in it and a picture over it included. + let framed = + view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none()).map(|s| s.index).filter(|i| { + images.get(*i).is_some_and(|image| on_picture(image) || quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p)) + }); + let index = images.iter().rposition(on_picture).or(framed)?; 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)) + let part = if framed.is_some_and(|i| i != index) { PicturePart::OverSelected } else { PicturePart::Body }; + Some(Hit::new(Owner::Picture { index, part }, Layer::Text, icon)) } /// What a right-click on a selected image asks for. @@ -620,15 +681,12 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap) } -#[doc(hidden)] -pub fn image_menu_id() -> egui::Id { - egui::Id::unique("image-menu") -} - -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +/// Images on a page: the press that is a picture's (`press`, [`body_hit`] and [`handle_hit`]) +/// selects it, a drag of the selected one moves, resizes or turns it. 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, press, gate } = *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; @@ -724,56 +782,25 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage } } - let mut handle_drag = None; - for (h, h_pos) in obb.handles() { - let handle_rect = Rect::from_center_size(h_pos, egui::vec2(18.0, 18.0)); - let handle_resp = ui.interact(handle_rect, ui.scope_id().with("img_handle").with(h), egui::Sense::drag()); - if allow_interaction && handle_resp.drag_started() { - handle_drag = Some(h); - } - } - let rot_id = ui.scope_id().with("img_handle").with(8); - let rot_rect = Rect::from_center_size(shown_knob, egui::vec2(12.0, 12.0)); - let rot_resp = ui.interact(rot_rect, rot_id, egui::Sense::drag()); - let rot_drag = allow_interaction && rot_resp.drag_started(); - - if allow_interaction && let Some(p) = hover_pos { - if shown_knob.distance(p) < 8.0 { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(p) < 8.0) { - let icon = obb.handle_cursor(*h); - view.cursor.claim(Layer::Handle, icon); - } - } - - let origin = ptr.origin; - if allow_interaction { - if let Some(h) = handle_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or_else(|| obb.handle_pos(h)); - s.drag = Some(ImageDrag { - start: o, - target: DragTarget::Handle(h), - initial_rect: b, - initial_matrix: resize_frame(&obb, xf, info, page).0, - }); - } - } else if rot_drag { - if let Some(s) = view.image_selection.as_mut() { - let o = origin.unwrap_or(shown_knob); + // A drag of the selected picture takes what its press was on ([`body_hit`], [`handle_hit`]): + // the rotate knob turns it, a grip resizes it, the picture itself moves. The knob and the + // grips take it from the press, so a move too short to be a drag of the page counts; the + // picture waits for that, as a click selects. + let at_press = |part| matches!(part, PicturePart::Knob | PicturePart::Corner(_)); + if gate + && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { index, part }) = press + && (index == i || part == PicturePart::OverSelected) + && (ptr.drag_started || at_press(part) && ptr.pressed().is_some()) + && let Some(s) = view.image_selection.as_mut().filter(|s| s.drag.is_none()) + { + match part { + PicturePart::Knob => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); s.turning = true; } - } else if resp.drag_started() - && let Some(o) = origin - { - if shown_knob.distance(o) < 8.0 { - if let Some(s) = view.image_selection.as_mut() { - s.drag = Some(ImageDrag { start: o, target: DragTarget::Rotate, initial_rect: b, initial_matrix: image.matrix }); - s.turning = true; - } - } else if let Some((h, _)) = obb.handles().iter().find(|(_, pos)| pos.distance(o) < 8.0) { - if let Some(s) = view.image_selection.as_mut() { + PicturePart::Corner(grip) => { + if let Some((h, _)) = obb.handles().get(grip) { s.drag = Some(ImageDrag { start: o, target: DragTarget::Handle(*h), @@ -781,11 +808,11 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage initial_matrix: resize_frame(&obb, xf, info, page).0, }); } - } else if (image_contains(i, o) || quad_contains(obb.corners(), o)) - && let Some(s) = view.image_selection.as_mut() - { + } + PicturePart::Body | PicturePart::OverSelected => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); } + PicturePart::Crop(_) => {} } } @@ -949,59 +976,35 @@ 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; + } + // 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() { + 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 => {} } } - if !allow_interaction { - return false; - } - let menu_key = image_menu_id(); - let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false }; - let Some(hit) = boxes.iter().enumerate().rposition(|(i, b)| { - b.is_positive() && images.get(i).is_some_and(|img| ImageObb::from_image(img, xf, info, page).contains(*b, p)) && image_contains(i, p) - }) else { - if resp.secondary_clicked() || resp.clicked() { - view.image_selection = None; - ui.ctx().data_mut(|d| d.insert_temp(menu_key, false)); - } - return false; - }; - let Some(hit_img) = images.get(hit) else { return false }; - let hit_obb = ImageObb::from_image(hit_img, xf, info, page); - if selected != Some(hit) { - if hit_obb.is_rotated { - let pts = hit_obb.corners(); - for k in 0..4 { - painter.line_segment([pts[k], pts[(k + 1) % 4]], Stroke::new(1.5, ACCENT.gamma_multiply(0.7))); - } - } else if let Some(b) = boxes.get(hit).copied() { - painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); + owner.is_some() +} + +/// A right-click on `page` that went to its picture `index` (on it or a grip of it) selects it for +/// its menu, keeping it as it is if it was selected (crop mode included); one that went to anything +/// else (`None`) unselects the picture selected. +pub(crate) fn picture_context(page: usize, index: Option, view: &mut DocView) { + match index { + Some(index) if view.image_selection.as_ref().is_none_or(|s| (s.page, s.index) != (page, index)) => { + view.image_selection = Some(ImageSelection::at(page, index)); } + Some(_) => {} + None => view.image_selection = None, } - let grip = hit_obb.handles().into_iter().find(|(_, point)| point.distance(p) < 10.0); - let cursor = if selected == Some(hit) { - grip.map_or(egui::CursorIcon::Grab, |(handle, _)| hit_obb.handle_cursor(handle)) - } else { - egui::CursorIcon::PointingHand - }; - view.cursor.claim(Layer::Text, cursor); - let clicked = match view.router.owner(page) { - Some(Owner::Picture { index, .. }) if resp.clicked() => Some(index), - _ => (resp.clicked() || resp.secondary_clicked()).then_some(hit), - }; - if let Some(index) = clicked { - view.image_selection = Some(ImageSelection::at(page, index)); - } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); - } - true } /// The right-click context menu for a selected image. @@ -1025,7 +1028,6 @@ pub(crate) fn image_context_menu( let picked = |ui: &mut egui::Ui, entry: egui::Response| { let clicked = entry.clicked(); if clicked { - ui.ctx().data_mut(|d| d.insert_temp(image_menu_id(), false)); egui::Popup::close_all(ui.ctx()); ui.close(); } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 63db81b9f3..f2e880983d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -95,7 +95,8 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { /// Link tool input on one page: `free` when nobody holds the press and it began on this page, /// `owner` the link that holds it (by its `/Annots` index), and `context` the link a right-click is -/// on. A drag from either draws a new link. Returns `true` when the gesture belongs to the tool. +/// on. A drag nobody holds draws a new link: a link lets its drag go. Returns `true` when the +/// gesture belongs to the tool. pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, context: Option, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; @@ -114,11 +115,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option>, pub notice: Option, pub hold_editor: bool, - blocked_page: Option, input_blocked: bool, /// A paragraph (page, block) to select once the document is at hand: Esc in the inline /// editor asks for it, and the next `prepare` selects it. @@ -40,23 +39,32 @@ struct Gesture { } impl ObjectSelection { - /// Clear any object-selection gesture and the page it was blocking. + /// Clear any object-selection gesture. pub(crate) fn cancel_pointer_gesture(&mut self) { self.gesture = None; - self.blocked_page = None; } pub fn count(&self) -> usize { self.selected.len() } + /// Edit text's input is blocked: a dialog, the palette, another window or another tab has it. + pub(crate) fn input_blocked(&self) -> bool { + self.input_blocked + } + + /// The objects selected together are on `page`, or a gesture of theirs runs there: the hover + /// outlines of what lies under them wait. + pub(crate) fn holds(&self, page: usize) -> bool { + self.page == Some(page) && (!self.selected.is_empty() || self.gesture.is_some()) + } + /// A preview belongs to the active, focused document. Returning from another tab or a /// modal must not turn a release that happened elsewhere into a document edit. pub fn set_input_blocked(&mut self, blocked: bool) { self.input_blocked = blocked; if blocked { self.gesture = None; - self.blocked_page = None; } } @@ -65,7 +73,6 @@ impl ObjectSelection { self.selected.clear(); self.gesture = None; self.pending = None; - self.blocked_page = None; self.select_paragraph = None; } @@ -139,7 +146,9 @@ impl ObjectSelection { Edit::MoveObjects { page, objects: self.selected.iter().map(|o| o.target).collect(), offset } } - fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { + /// The objects of `page` that can be selected together, as `doc.editable_objects` lists them, or + /// why they can't be. + pub(crate) fn inventory(&mut self, doc: &Document, page: usize) -> Result, String> { if self.cache.len() >= 8 && !self.cache.contains_key(&page) { self.cache.clear(); } @@ -151,18 +160,15 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) xf.user_rect(info, page, object.rect.map(|v| v as f32)) } -fn modified(ui: &egui::Ui) -> bool { - ui.input(|i| { - let modifiers = i - .events - .iter() - .find_map(|e| match e { - egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: true, modifiers, .. } => Some(*modifiers), - _ => None, - }) - .unwrap_or(i.modifiers); - modifiers.shift || modifiers.ctrl || modifiers.command - }) +/// A menu is open (the page's, or another popup): the objects selected together leave the pointer +/// alone, and a press with Shift or ⌘ is not theirs. +pub(crate) fn menu_open(ui: &egui::Ui, resp: &egui::Response) -> bool { + resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) +} + +/// Whether `modifiers` make a press add to the objects selected together, or take from them. +pub(crate) fn adds(modifiers: egui::Modifiers) -> bool { + modifiers.shift || modifiers.ctrl || modifiers.command } fn single(view: &DocView, page: usize) -> Option { @@ -187,6 +193,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) { @@ -195,27 +205,75 @@ pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, o open.filter(|r| on_frame(r.shrink(2.0), p)).map(|_| Hit::new(Owner::Objects { part: ObjectsPart::Frame }, Layer::Handle, egui::CursorIcon::Move)) } -/// Returns true while group selection owns the interaction. Ordinary clicks still reach the -/// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, .. } = cx; +/// The object under `p` among the page's `objects`: added content over text over pictures, so a +/// page-sized background picture does not hide editable labels or added notes, and within each +/// kind the last, as the single-item tools take it. +fn object_at(xf: &PageXform, info: &DocInfo, page: usize, objects: &[EditableObject], p: Pos2) -> Option { + [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] + .into_iter() + .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))) + .map(|o| o.target) +} + +/// What a press with Shift or ⌘ at `p` takes on `page` in Edit text, whatever lies there: the +/// object under it among the page's `objects` ([`ObjectSelection::inventory`]), which it adds to +/// the objects selected together or takes out of them, or none, where it starts an area selection +/// that adds to them. Held while text being typed is open, and refused on a page whose objects +/// can't be selected together. `None` off the page and outside the pages' visible area (`clip`). +pub(crate) fn modified_hit( + view: &DocView, + xf: &PageXform, + info: &DocInfo, + page: usize, + objects: &Result, String>, + clip: Rect, + p: Pos2, +) -> Option { + if !xf.rect.contains(p) || !clip.contains(p) { + return None; + } + let part = if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { + ObjectsPart::Held + } else { + match objects { + Ok(objects) => ObjectsPart::Toggle(object_at(xf, info, page, objects, p)), + Err(_) => ObjectsPart::Refused, + } + }; + Some(Hit::new(Owner::Objects { part }, Layer::Editor, egui::CursorIcon::Default)) +} + +/// The objects selected together, on one page, told what the press is to it (`press`). Returns +/// true while they own the interaction: while input is blocked, while a press with Shift or ⌘ is +/// held ([`modified_hit`]), and while objects on this page are selected or a gesture of theirs +/// runs. Ordinary presses on a paragraph, a picture or added content still reach their own tools. +/// A text draft is finished or discarded through its editor. +pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { + let PageToolContext { ui, resp, ptr, page, .. } = cx; if view.objects.input_blocked { return true; } - if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { + if menu_open(ui, resp) { return false; } - if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { - let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); - if blocked || modifier_press { + match press { + // Text being typed keeps the keyboard, and its editor stays open, until the button is up. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Held }) => { view.objects.hold_editor = true; - view.objects.blocked_page = ptr.primary_down.then_some(page); - if modifier_press { + if ptr.pressed().is_some() { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } return true; } + PagePress::Mine(Owner::Objects { part: ObjectsPart::Refused }) => { + if ptr.pressed().is_some() { + view.objects.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + } + return true; + } + _ => {} + } + if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { return false; } // Delete or Backspace removes the selected objects on this page as one edit, @@ -235,12 +293,12 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, view: &mut DocView) -> bool { } } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, cx, view); + let result = input(&mut selection, cx, press, view); view.objects = selection; result } -fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -> bool { +fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, view: &mut DocView) -> bool { let PageToolContext { ui, resp, xf, ptr, page, doc, .. } = cx; let pointer = ptr.pos; let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); @@ -298,73 +356,68 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) - let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else { return s.page == Some(page) && !s.selected.is_empty(); }; - if !pressed { + // Nor a press another page holds, on the row of pixels pages share with the gaps hidden: it may + // have just selected something there. + if !pressed || press == PagePress::Elsewhere { return s.page == Some(page) && !s.selected.is_empty(); } - let objects = match s.inventory(doc, page) { - Ok(objects) => objects, - Err(_) => { - if modified(ui) { - s.notice = Some(tl!("This page contains objects that cannot be selected together.").into()); + let Ok(objects) = s.inventory(doc, page) else { return false }; + // What the press was on ([`hit`], [`modified_hit`]). + match press { + // With Shift or ⌘: the object it was on joins the selection or leaves it, and on none an + // area selection adds to it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Toggle(target) }) => { + if s.page != Some(page) { + s.clear(); + s.page = Some(page); + if let Some(single) = single(view, page) + && let Some(object) = objects.iter().find(|o| o.target == single) + { + s.selected.push(object.clone()); + } + } + match target.and_then(|t| objects.iter().find(|o| o.target == t)) { + Some(object) => { + if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { + s.selected.remove(index); + } else if s.selected.len() < MAX_MOVE_OBJECTS { + s.selected.push(object.clone()); + } else { + s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); + } + } + None => s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }), + } + leave_single(view); + view.current = page; + return true; + } + // The selection moves. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) => { + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + return true; + } + // The frame of the paragraph open for typing selects its box (for the arrow keys) and + // moves it. + PagePress::Mine(Owner::Objects { part: ObjectsPart::Frame }) => { + if let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) + && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) + { + s.clear(); + s.page = Some(page); + s.selected.push(object.clone()); + s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); + leave_single(view); + view.current = page; return true; } - return false; } - }; - let additive = modified(ui); - // A page-sized background image must not hide editable labels or added notes. - // Within each kind, prefer the last object as the ordinary single-item tools do. - let hit = [ObjectKind::Added, ObjectKind::Text, ObjectKind::Image] - .into_iter() - .find_map(|kind| objects.iter().rev().find(|o| o.target.kind == kind && screen(xf, info, page, o).expand(2.0).contains(p))); - if additive { - if s.page != Some(page) { - s.clear(); - s.page = Some(page); - if let Some(target) = single(view, page) - && let Some(object) = objects.iter().find(|o| o.target == target) - { - s.selected.push(object.clone()); - } - } - if let Some(object) = hit { - if let Some(index) = s.selected.iter().position(|o| o.target == object.target) { - s.selected.remove(index); - } else if s.selected.len() < MAX_MOVE_OBJECTS { - s.selected.push(object.clone()); - } else { - s.notice = Some(tl!("Select at most 1000 objects at a time.").into()); - } - } else { - s.gesture = Some(Gesture { start: p, now: p, marquee: true, additive: true }); - } - leave_single(view); - view.current = page; - return true; - } - // What the press was on ([`hit`]): the selection moves; the frame of the paragraph open for - // typing selects its box (for the arrow keys) and moves it. - let owner = view.router.owner(page); - if owner == Some(Owner::Objects { part: ObjectsPart::Selection }) && !resp.double_clicked() { - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - return true; - } - if owner == Some(Owner::Objects { part: ObjectsPart::Frame }) - && let Some(block) = view.line_editor.as_ref().filter(|e| e.page == page && !e.has_changes()).map(|e| e.block) - && let Some(object) = objects.iter().find(|o| o.target == ObjectTarget { kind: ObjectKind::Text, index: block }) - { - s.clear(); - s.page = Some(page); - s.selected.push(object.clone()); - s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); - leave_single(view); - view.current = page; - return true; + _ => {} } s.clear(); // A press on something (a paragraph, its edge reaching past its box #2276, a picture, added - // content) is its own; only one on the empty page starts an area selection. - if hit.is_some() || view.router.owner(page).is_some() { + // content) is its own; only one nobody holds starts an area selection. + if press != PagePress::Free { return false; } leave_single(view); @@ -589,7 +642,7 @@ mod tests { }, |ui| { let resp = ui.interact(ui.available_rect_before_wrap(), egui::Id::unique("page"), egui::Sense::click()); - page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, &mut view); + page_input(PageToolContext { ui, resp: &resp, xf: &xf, ptr: &ptr, page: 0, doc, info: &info }, PagePress::Free, &mut view); }, ); output.textures_delta.clear(); diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 602358d1e0..405ce5ac84 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -380,7 +380,8 @@ pub(crate) fn page_input( // The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody // holds draws a selection rectangle, or a field with a field tool. A press held by something - // drawn over the fields (a comment) is not Prepare's. + // drawn over the fields (a comment) is not Prepare's, though its drag is once that something + // lets it go (a highlight's, `PageRouter::drag_began`). let free = press == PagePress::Free; let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); let hit = match press { @@ -390,7 +391,7 @@ pub(crate) fn page_input( // A drag starts from where its press began on this page: egui may see it only once the pointer // is past the page's edge. if allowed - && resp.drag_started() + && ptr.drag_started && (held || free) && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) { diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 345e7e5d77..01c74c46ea 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -269,7 +269,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo /// A finished text selection with the Redact tool becomes a mark. pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, view: &mut DocView) { - if !(resp.drag_stopped() || resp.double_clicked()) || view.redact_drag.is_some() { + if !(resp.drag_stopped_by(egui::PointerButton::Primary) || resp.double_clicked()) || view.redact_drag.is_some() { return; } if let Some((p, quads)) = view.selection_quads(info).filter(|(p, _)| *p == page) { diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 8405446df2..eb2bfa7afe 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -717,14 +717,22 @@ fn form_widget_keeps_priority_over_highlighted_link() { assert_eq!(h.state().last_opened_url, None); } +/// A comment's gesture keeps the page's input until its button is up: a press held through it and +/// released on a highlighted link follows no link. (A gesture left over once the button is up +/// ends: `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.) #[test] fn active_comment_gesture_keeps_highlighted_link_click() { let _gpu = gpu(); let mut h = overlapping_annotation_harness(overlapping_annotation_fixture("Highlight", false)); let r = h.state().views[0].page_screen_rect(0).unwrap(); let at = egui::pos2(r.min.x + r.width() * 0.5, r.min.y + r.height() * (1.0 - 325.0 / 400.0)); + h.hover_at(at); + h.run_steps(2); + h.drag_at(at); + h.run_steps(1); h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 1, from: at }); - click_at(&mut h, at); + h.drop_at(at); + h.run_steps(4); assert_eq!(h.state().pending_link, None); assert_eq!(h.state().last_opened_url, None); } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c4231d0f69..aaad18b48e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -2588,7 +2588,7 @@ fn protected_with(algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, p } /// `bytes` rewritten encrypted. -fn encrypted(bytes: Vec, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { +pub(crate) fn encrypted(bytes: Vec, algorithm: pdfcraft_cos::Algorithm, user: &str, owner: &str, permissions: i32) -> Vec { let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); doc.set_encryption(&pdfcraft_cos::NewEncryption { algorithm, @@ -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)); @@ -7230,6 +7234,848 @@ 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> { + 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, pdf).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)); +} + +/// With Edit text, a right-click on text lying over a picture is the picture's, which is selected +/// for its menu: paragraphs have no menu of their own (the user's ruling). +#[test] +fn a_right_click_on_text_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.run_steps(2); + // "Overlay" lies on the picture, at x 40, y 220. + let overlay = page_at(&h, 0, 50.0, 224.0); + right_click_at(&mut h, overlay); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the text is selected for its menu"); +} + +/// With Edit text, a right-click on added text over a picture is the picture's, which is selected +/// for its menu: added text has no menu of its own. +#[test] +fn a_right_click_on_added_text_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [60.0, 190.0, 150.0, 205.0], text: "Added".into(), ..Default::default() }, + })); + h.run_steps(3); + let on_it = page_at(&h, 0, 100.0, 198.0); + right_click_at(&mut h, on_it); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the added text is selected for its menu"); + assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); +} + +/// With Edit text, a right-click on the frame of a paragraph open for typing over a picture is the +/// picture's too. +#[test] +fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.run_steps(2); + // "Overlay" lies on the picture, at x 40, y 220. + let overlay = page_at(&h, 0, 50.0, 224.0); + slipping_click(&mut h, overlay, overlay); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing"); + let id = h.state().views[0].id; + let blocks = h.state().session.get(id).unwrap().text_blocks(0); + let block = + blocks.iter().find(|b| b.rect[0] < 50.0 && 50.0 < b.rect[2] && b.rect[1] < 224.0 && 224.0 < b.rect[3]).expect("the paragraph opened").clone(); + // Its frame runs just outside its box. + let frame = page_at(&h, 0, block.rect[0] as f32, ((block.rect[1] + block.rect[3]) / 2.0) as f32) - egui::vec2(3.0, 0.0); + right_click_at(&mut h, frame); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + assert_eq!(selected, Some((0, 0)), "the picture under the frame is selected for its menu"); + assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); +} + +/// A drag from the selected picture moves it, though another picture lies over it there; a click +/// there selects the one on top. +#[test] +fn a_drag_from_the_selected_picture_under_another_moves_the_selected_one() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("two.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + // The second picture lies over the right half of the first. + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 1, + change: pdfcraft_engine::ImageEdit::Move([110.0, 100.0, 130.0, 120.0]), + }); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(2); + let id = h.state().views[0].id; + let rects = |h: &Harness<'static, PdfCraftApp>| { + let images = h.state().session.get(id).unwrap().page_images(0); + (images[0].rect, images[1].rect) + }; + let before = rects(&h); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let images = doc.page_images(0); + let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0); + let overlap = frame(0).intersect(frame(1)).center(); + h.hover_at(overlap); + h.run_steps(1); + h.drag_at(overlap); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(overlap + egui::vec2(8.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(overlap + egui::vec2(32.0, 0.0)); + h.run_steps(3); + let after = rects(&h); + assert_ne!(after.0, before.0, "the selected picture moves"); + assert_eq!(after.1, before.1, "the one over it stays"); + let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index); + assert_eq!(selected, Some(0), "and stays selected"); + // After the move the two still overlap, a little further along. + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let images = doc.page_images(0); + let frame = |i: usize| pdfcraft_ui_egui::canvas::image_screen_rect(&images[i], &xf, &doc.info, 0); + let overlap = frame(0).intersect(frame(1)).center(); + slipping_click(&mut h, overlap, overlap); + let selected = h.state().views[0].image_selection.as_ref().map(|s| s.index); + assert_eq!(selected, Some(1), "a click selects the one on top"); +} + +/// A move too short to be a drag of the page still resizes the selected picture from its grip, as +/// the grip takes its drag from the press. +#[test] +fn a_short_drag_from_a_picture_grip_resizes_it() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].matrix; + // The picture shows at x 30..160, y 170..250: its lower right grip, moved 3 px. + let grip = page_at(&h, 0, 160.0, 170.0); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + h.hover_at(grip + egui::vec2(3.0, 0.0)); + h.run_steps(1); + h.drop_at(grip + egui::vec2(3.0, 0.0)); + h.run_steps(3); + assert_ne!(h.state().session.get(id).unwrap().page_images(0)[0].matrix, before, "the picture is resized"); +} + +/// A click on a selected picture's grip that doesn't move resizes nothing: no edit, and the picture +/// stays selected. +#[test] +fn a_click_on_a_picture_grip_changes_nothing() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().edit_generation(); + let grip = page_at(&h, 0, 160.0, 170.0); + slipping_click(&mut h, grip, grip); + assert_eq!(h.state().session.get(id).unwrap().edit_generation(), before, "no edit"); + assert!(h.state().views[0].image_selection.is_some(), "the picture stays selected"); +} + +/// With Add text holding text that can't be drawn, a drag from a paragraph moves nothing: the draft +/// stays where it is being typed (#125), as a click there leaves it. +#[test] +fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() { + if crate::support::skip_without_glyphs("add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw", "en", "Hello 世界") { + return; + } + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let blank = point_on(&h, block.rect[0], block.rect[3] + 12.0); + slipping_click(&mut h, blank, blank); + h.event(egui::Event::Text("Hello 世界".into())); + h.run_steps(2); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("Hello 世界"), "the draft holds the text"); + let rect = h.state().views[0].content.draft.as_ref().map(|d| d.rect).unwrap(); + let on_text = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, (block.rect[1] + block.rect[3]) / 2.0); + h.hover_at(on_text); + h.run_steps(1); + h.drag_at(on_text); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(on_text + egui::vec2(10.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(on_text + egui::vec2(40.0, 0.0)); + h.run_steps(3); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, block.rect, "the paragraph stays"); + let draft = h.state().views[0].content.draft.as_ref().map(|d| (d.text.clone(), d.rect)); + assert_eq!(draft, Some(("Hello 世界".to_string(), rect)), "and the draft stays where it is typed"); +} + +/// With Edit text, a right-click on a comment over a picture opens the comment's menu, and the +/// picture is not selected for its own. +#[test] +fn a_right_click_on_a_comment_over_a_picture_opens_its_menu() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + let shape = pdfcraft_engine::Shape::Rectangle { rect: [60.0, 190.0, 150.0, 230.0] }; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: 0, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: "Reviewed".into(), + author: "Tester".into(), + }))); + h.state_mut().views[0].comments.selected = None; + h.run_steps(3); + let on_it = page_at(&h, 0, 100.0, 210.0); + right_click_at(&mut h, on_it); + assert!(h.state().views[0].comments.selected.is_some(), "the comment is selected"); + assert!(h.state().views[0].image_selection.is_none(), "the picture is not"); + assert!(h.query_by_label("Properties…").is_some(), "the comment's menu opens"); +} + +/// In crop mode, a right-click on the next page goes to what lies there, though a clipped artwork's +/// whole frame reaches over it: crop mode ends and the page's menu opens. +#[test] +fn a_right_click_on_the_next_page_leaves_crop_mode() { + let mut h = picture_pages(true); + let on_two = page_at(&h, 1, 100.0, 40.0); + right_click_at(&mut h, on_two); + assert!(h.state().views[0].image_selection.is_none(), "crop mode ends"); + assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu opens"); +} + +/// A crop grip takes a press as far off as its cursor shows: 9 px from the grip, outside the box. +#[test] +fn a_crop_grip_takes_a_press_within_its_reach() { + let mut h = picture_pages(true); + 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); + let before = keep(&h); + // The box kept is the picture's frame shown, x 30..160, y 170..250: its upper left corner. + let press = page_at(&h, 0, 30.0, 250.0) - egui::vec2(9.0, 0.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(press + egui::vec2(8.0 * k as f32, 8.0 * k as f32)); + h.run_steps(1); + } + h.drop_at(press + egui::vec2(32.0, 32.0)); + h.run_steps(2); + assert_ne!(keep(&h), before, "the grip takes the drag"); +} + +/// With Add text, a click on another paragraph keeps both what was typed in the open paragraph and +/// the text being added: neither edit takes the other's place. +#[test] +fn add_text_keeps_a_changed_paragraph_and_a_draft_when_it_moves_on() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + let id = h.state().views[0].id; + let blocks = h.state().session.get(id).unwrap().text_blocks(0); + assert!(blocks.len() >= 2, "two paragraphs: {blocks:?}"); + let centre = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (ax, ay) = centre(blocks[0].rect); + let (bx, by) = centre(blocks[1].rect); + let a = page_at(&h, 0, ax as f32, ay as f32); + let b = page_at(&h, 0, bx as f32, by as f32); + slipping_click(&mut h, a, a); + assert!(h.state().views[0].line_editor.is_some(), "the first paragraph is open"); + h.event(egui::Event::Text("X".into())); + h.run_steps(3); + // A click on the page's empty part, below the picture, starts a box. + let blank = page_at(&h, 0, 180.0, 20.0); + slipping_click(&mut h, blank, blank); + assert!(h.state().views[0].line_editor.is_none(), "that click applied the paragraph"); + h.event(egui::Event::Text("Added words".into())); + h.run_steps(3); + slipping_click(&mut h, b, b); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + let texts: Vec = doc.text_blocks(0).iter().map(|b| b.text.clone()).collect(); + let added: Vec = doc + .added() + .iter() + .filter_map(|a| match &a.content { + pdfcraft_engine::AddedContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .collect(); + assert!(added.iter().any(|t| t == "Added words"), "the added text is kept: {added:?}"); + assert!(texts.iter().any(|t| t.contains('X')), "the paragraph's typing is kept: {texts:?}"); +} + +/// In the page grid, Delete deletes the page chosen there. The picture selected before the grid +/// opened is not on screen, and stays. +#[test] +fn delete_in_the_page_grid_deletes_the_page_and_not_the_hidden_picture() { + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + let pages = h.state().session.get(id).unwrap().info.pages.len(); + assert!(h.state_mut().execute("page.organize")); + h.run_steps(4); + h.get_by_label("Select page 2").click(); + h.run_steps(2); + h.key_press(egui::Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert_eq!(doc.info.pages.len(), pages - 1, "the page chosen in the grid is deleted"); + assert_eq!(doc.page_images(0).len(), pictures, "the picture stays"); +} + +/// With the picture's page not on screen, its keys do nothing: Delete removes no picture the reader +/// can't see, and Enter applies no crop. +#[test] +fn a_selected_pictures_keys_wait_for_its_page_to_be_on_screen() { + use pdfcraft_ui_egui::canvas::PageLayout; + for (crop, key) in [(false, egui::Key::Delete), (true, egui::Key::Enter)] { + let mut h = picture_pages(crop); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0); + if crop { + let keep = before[0].visible_rect.unwrap(); + let half = [keep[0], keep[1], (keep[0] + keep[2]) / 2.0, keep[3]]; + h.state_mut().views[0].image_selection.as_mut().unwrap().crop.as_mut().unwrap().keep = half; + } + h.state_mut().views[0].layout = PageLayout::Single; + h.state_mut().views[0].continuous = false; + h.state_mut().views[0].go_to_page(1); + h.run_steps(4); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen"); + h.key_press(key); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(0); + assert_eq!(after.len(), before.len(), "the picture stays"); + assert_eq!(after[0].visible_rect, before[0].visible_rect, "and is not cropped"); + } + // A page just off the screen, in a continuous view: it is still run for what lies near it. + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + h.set_size(egui::vec2(1400.0, 400.0)); + h.state_mut().views[0].go_to_page(1); + h.run_steps(6); + // Going to a page leaves a margin above it: a little further, past page 1's foot. + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(1); + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Trackpad, + }); + h.run_steps(6); + assert!(h.state().views[0].continuous, "a continuous view"); + assert!(h.state().views[0].page_screen_rect(0).is_none(), "page 1 is off the screen"); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays"); +} + +/// Delete waits for the button to come up: the press held would then name another item, whose drag or +/// click would follow. +#[test] +fn delete_waits_for_the_button_to_come_up() { + let hold_and_delete = |h: &mut Harness<'static, PdfCraftApp>, on: egui::Pos2| { + h.hover_at(on); + h.run_steps(1); + h.drag_at(on); + h.run_steps(1); + h.key_press(egui::Key::Delete); + h.run_steps(2); + h.drop_at(on); + h.run_steps(3); + }; + let mut h = picture_pages(false); + let id = h.state().views[0].id; + let pictures = h.state().session.get(id).unwrap().page_images(0).len(); + let on_picture = page_at(&h, 0, 100.0, 180.0); + hold_and_delete(&mut h, on_picture); + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), pictures, "the picture stays"); + + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + let on_added = point_on(&h, 90.0, 258.0); + slipping_click(&mut h, on_added, on_added); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text is selected"); + hold_and_delete(&mut h, on_added); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().added().len(), 1, "the added text stays"); +} + +/// A drag the page it is over first sees lets the picked picture go, whichever page it began on. +#[test] +fn a_drag_first_seen_on_the_next_page_lets_the_picked_picture_go() { + let mut h = picture_pages(false); + h.state_mut().views[0].image_selection = None; + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + let on_picture = page_at(&h, 0, 100.0, 180.0); + slipping_click(&mut h, on_picture, on_picture); + assert!(h.state().views[0].picked_image.is_some(), "the picture is picked"); + let press = page_at(&h, 0, 180.0, 20.0); + let release = page_at(&h, 1, 180.0, 280.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(release); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); + assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go"); +} + +/// With the pointer on the Pages panel, Delete is the panel's: a page goes, though a picture is +/// selected on the pages. +#[test] +fn delete_with_the_pointer_on_the_pages_panel_deletes_a_page() { + let mut h = picture_pages(false); + h.state_mut().set_option("panel", "pages").unwrap(); + h.run_steps(4); + let id = h.state().views[0].id; + let pages = h.state().session.get(id).unwrap().info.pages.len(); + assert!(h.state().views[0].image_selection.is_some(), "the picture is selected"); + assert!(h.state().views[0].page_screen_rect(0).is_some(), "on a page on screen"); + let viewport = h.state().views[0].viewport_rect(); + assert!(viewport.right() < 1300.0, "the panel lies right of the pages: {viewport:?}"); + h.hover_at(egui::pos2(viewport.right() + 60.0, viewport.center().y)); + h.run_steps(2); + h.key_press(egui::Key::Delete); + h.run_steps(4); + assert_eq!(h.state().session.get(id).unwrap().info.pages.len(), pages - 1, "a page goes"); +} + +/// In crop mode, a right-click on another picture selects it for its menu, as without crop mode. It +/// went to crop mode's whole-page hit, which unselected every picture. +#[test] +fn in_crop_mode_a_right_click_on_another_picture_selects_it() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("two.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + let id = h.state().views[0].id; + let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(rect)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let other = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[1], &xf, &doc.info, 0); + crop_secondary_click(&mut h, other.center()); + let selection = h.state().views[0].image_selection.clone(); + assert!(selection.as_ref().is_some_and(|s| s.index == 1 && s.crop.is_none()), "the other picture is selected: {selection:?}"); + 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. +#[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). +#[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. +#[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] +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()); +} + +/// Delete on a selected added picture deletes it alone: the added text placed after it stays. The +/// key's second reader, added content's own Delete, took the text that the picture's index then +/// named. +#[test] +fn delete_on_a_selected_added_picture_deletes_it_alone() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); + let mut png = Vec::new(); + image::RgbImage::from_pixel(30, 20, image::Rgb([20, 90, 200])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + h.state_mut().add_image("logo.png".into(), png); + h.run_steps(3); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [20.0, 20.0, 120.0, 35.0], text: "Caption".into(), ..Default::default() }, + })); + h.run_steps(3); + let id = h.state().views[0].id; + let images = h.state().session.get(id).unwrap().page_images(0).len(); + assert_eq!(images, 1, "the added picture is the page's picture"); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(3); + h.key_press(Key::Delete); + h.run_steps(4); + let doc = h.state().session.get(id).unwrap(); + assert!(doc.page_images(0).is_empty(), "the picture is deleted"); + let texts: Vec<&str> = doc + .added() + .iter() + .filter_map(|a| if let pdfcraft_engine::AddedContent::Text(t) = &a.content { Some(t.text.as_str()) } else { None }) + .collect(); + assert_eq!(texts, ["Caption"], "and the text placed after it stays"); +} + +/// With a dialog open, Delete and Backspace are the dialog's: the selected picture stays. +#[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)); +} + +/// A click that slips a few pixels between its press and its release, within egui's click distance. +fn slipping_click(h: &mut Harness<'static, PdfCraftApp>, press: egui::Pos2, release: egui::Pos2) { + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.drop_at(release); + h.run_steps(4); +} + +/// [`harness`]'s page 1 in Edit text (`tool`), and the screen point at PDF (x, y) on it. +fn edit_page(tool: pdfcraft_ui_egui::QuickTool) -> Harness<'static, PdfCraftApp> { + let mut h = harness(2, |_| {}); + h.state_mut().quick_tool = tool; + h.run_steps(3); + h +} + +fn point_on(h: &Harness<'static, PdfCraftApp>, x: f64, y: f64) -> egui::Pos2 { + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + egui::pos2(r.left() + x as f32 * k, r.top() + (300.0 - y as f32) * k) +} + +/// A click on a paragraph opens it where it was pressed, though the button came up just below its +/// box: an owner acts where its press was. It opened nothing. +#[test] +fn a_click_on_a_paragraph_released_just_below_it_opens_it() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + // The box reaches 2 px below the text: pressed 1 px inside it, released 3 px below it. + let bottom = point_on(&h, (block.rect[0] + block.rect[2]) / 2.0, block.rect[1]) + egui::vec2(0.0, 2.0); + slipping_click(&mut h, bottom - egui::vec2(0.0, 1.0), bottom + egui::vec2(0.0, 3.0)); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing"); +} + +/// With the Select tool and the Edit panel open, a click on added text selects it where it was +/// pressed, though the button came up just beside it. It left nothing selected. +#[test] +fn a_click_on_added_text_released_just_beside_it_selects_it() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::Select); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 250.0, 140.0, 265.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Select; + h.run_steps(3); + // The item takes presses 2 px past its box: pressed 1 px inside that, released 3 px past it. + let edge = point_on(&h, 140.0, 258.0) + egui::vec2(2.0, 0.0); + slipping_click(&mut h, edge - egui::vec2(1.0, 0.0), edge + egui::vec2(3.0, 0.0)); + assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the added text pressed is selected"); +} + +/// With Add text, a click pressed beside a paragraph and released inside its box starts a new box +/// where it was pressed. It finished the text being typed and placed nothing. +#[test] +fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::AddText); + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + // The box reaches 2 px past the text: pressed 2 px left of it, released 2 px inside it. + let left = point_on(&h, block.rect[0], (block.rect[1] + block.rect[3]) / 2.0) - egui::vec2(2.0, 0.0); + slipping_click(&mut h, left - egui::vec2(2.0, 0.0), left + egui::vec2(2.0, 0.0)); + let draft = h.state().views[0].content.draft.as_ref().map(|d| d.rect); + let x = draft.map(|r| r[0]); + assert!(x.is_some_and(|x| x < block.rect[0]), "a new box at the press, left of the paragraph: {x:?} of {:?}", block.rect); + assert!(h.state().views[0].line_editor.is_none(), "the paragraph stays closed"); +} + +/// A paragraph drag in Edit text ends when Edit text's input is blocked (the window loses focus), as +/// a drag of the objects selected together does: nothing moves once the input comes back. It +/// waited, and moved the paragraph to wherever the pointer then was. +#[test] +fn a_paragraph_drag_ends_when_the_window_loses_focus() { + for tool in [pdfcraft_ui_egui::QuickTool::EditText, pdfcraft_ui_egui::QuickTool::AddText] { + let mut h = edit_page(tool); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + let from = point_on(&h, (before[0] + before[2]) / 2.0, (before[1] + before[3]) / 2.0); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + egui::vec2(0.0, 10.0 * k as f32)); + h.run_steps(1); + } + assert!(h.state().views[0].block_drag.is_some(), "the paragraph is being moved"); + h.input_mut().focused = false; + h.run_steps(2); + h.drop_at(from + egui::vec2(0.0, 40.0)); + h.run_steps(2); + h.input_mut().focused = true; + h.hover_at(from + egui::vec2(0.0, 80.0)); + h.run_steps(4); + assert!(h.state().views[0].block_drag.is_none(), "the drag is over"); + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, before, "nothing moved"); + assert!(!dirty(&h)); + } +} + +/// The rotate knob of a picture that fills its page lies past the page's edge, and a drag there +/// still turns the picture: the press is the page's whose knob it is. +#[test] +fn dragging_the_rotate_knob_past_the_page_edge_turns_the_picture() { + 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); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + // Above the top edge, or below the bottom one when the view has no room above. + let knob = [egui::pos2(r.center().x, r.top() - 22.0), egui::pos2(r.center().x, r.bottom() + 22.0)] + .into_iter() + .find(|p| cursor_over(&mut h, *p) == egui::CursorIcon::Grab) + .expect("the knob past the page's edge"); + assert!(!r.contains(knob)); + let to = egui::pos2(r.right() + 60.0, r.center().y); + h.hover_at(knob); + h.run_steps(1); + h.drag_at(knob); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(knob + (to - knob) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(4); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Rotate image")); +} + /// 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] @@ -8021,6 +8867,34 @@ fn test_image_context_menu_crop_overlapping_image_and_handle() { } } +/// A right-click on the part of a picture its crop cuts away is the crop's, as one on the box kept: +/// crop mode stays and no menu opens (#5835). +#[test] +fn a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1400.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("cut.pdf", None, two_image_fixture()).unwrap(); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + let rect = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(4); + // Keep the picture's left half. + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new([rect[0], rect[1], (rect[0] + rect[2]) / 2.0, rect[3]])); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(2); + let xf = h.state().views[0].page_xform(0).unwrap(); + let doc = h.state().session.get(id).unwrap(); + let b = pdfcraft_ui_egui::canvas::image_screen_rect(&doc.page_images(0)[0], &xf, &doc.info, 0); + assert!(b.width() > 60.0, "the picture is wide enough for a point clear of the grips: {b:?}"); + crop_secondary_click(&mut h, b.lerp_inside(egui::vec2(0.8, 0.5))); + assert_crop_context_preserved(&h); +} + #[test] fn test_image_context_menu_crop_page_edge_pills() { for rotation in [0, 90, 180, 270] { diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index fc2a8e4fe7..21ebc90b69 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -385,3 +385,122 @@ fn modifier_selection_picks_foreground_text_and_added_items_over_background_artw assert!(new.rect.iter().enumerate().all(|(i, value)| (value - old.rect[i] - shift[i % 2]).abs() < 0.01), "{old:?} -> {new:?}"); } } + +/// A press with Shift while a paragraph with changes is open is held until the button is up: it +/// says why, the paragraph keeps its text, and nothing under the press is moved or selected, not +/// even once the press turns into a drag. +#[test] +fn a_shift_click_with_a_changed_paragraph_open_shows_the_notice_and_moves_nothing() { + let mut h = harness(); + let before = objects(&h); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + h.state_mut().views[0].line_editor.as_mut().unwrap().text = "A draft still being edited".into(); + let (a, b) = (at(&h, 60.0, 145.0), at(&h, 120.0, 200.0)); + h.hover_at(a); + h.run_steps(2); + h.event_modifiers( + egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::SHIFT }, + Modifiers::SHIFT, + ); + h.run_steps(2); + let notice = "Finish or discard the text being edited before selecting several objects."; + assert!(h.state().toast.as_ref().is_some_and(|t| t.0.contains(notice)), "the notice says why"); + for k in 1..=3 { + h.hover_at(a + (b - a) * (k as f32 / 3.0)); + h.run_steps(2); + } + h.drop_at(b); + h.run_steps(4); + assert_eq!(h.state().views[0].line_editor.as_ref().map(|e| e.text.as_str()), Some("A draft still being edited")); + assert_eq!(objects(&h), before, "nothing moved"); + assert!(h.state().views[0].image_selection.is_none() && h.state().views[0].content.selected.is_none(), "nothing selected"); + assert!( + h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(), + "no objects selected" + ); +} + +/// A press beside a picture, within the reach the objects selected together are found in but +/// outside the picture itself, is nobody's: it starts an area selection. It used to start nothing. +#[test] +fn a_press_just_outside_a_picture_starts_an_area_selection() { + let mut h = harness(); + // The figure is x 40..100, y 130..160 from the page's top; the added note x 130..230, y 130..144. + let (a, b) = (at(&h, 38.5, 140.0), at(&h, 235.0, 175.0)); + h.hover_at(a); + h.run_steps(2); + h.drag_at(a); + h.run_steps(2); + h.hover_at(b); + h.run_steps(2); + h.drop_at(b); + 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. +#[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"); +} + +/// A double-click on a paragraph selected with others opens the one its press was on, though the +/// button came up just off it. +#[test] +fn a_double_click_on_a_grouped_paragraph_released_just_off_it_opens_it() { + let mut h = harness(); + marquee(&mut h); + let (inside, outside) = { + let app = h.state(); + let view = &app.views[0]; + let xf = view.page_xform(0).unwrap(); + let doc = app.session.get(view.id).unwrap(); + let block = doc.text_blocks(0).into_iter().find(|b| b.text.contains("Group headline")).expect("the headline"); + let frame = xf.user_rect(&doc.info, 0, block.rect.map(|v| v as f32)); + (egui::pos2(frame.center().x, frame.top() + 1.0), egui::pos2(frame.center().x, frame.top() - 4.0)) + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.hover_at(inside); + h.run_steps(1); + crate::support::input::one_frame(&mut h, [button(inside, true), button(inside, false)]); + crate::support::input::one_frame(&mut h, [button(inside, true), egui::Event::PointerMoved(outside), button(outside, false)]); + h.run_steps(2); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph pressed is open for typing"); +} + +/// A press counts its own modifiers, whatever the frame's are: Shift in the press event adds to the +/// selection, though the frame's modifiers show none. +#[test] +fn a_press_counts_the_modifiers_of_its_own_event() { + let mut h = harness(); + let p = at(&h, 55.0, 70.0); + click(&mut h, p, Modifiers::NONE); + assert!(h.state().views[0].line_editor.is_some()); + let p = at(&h, 60.0, 145.0); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT }; + h.hover_at(p); + h.run_steps(2); + crate::support::input::one_frame(&mut h, [button(true), button(false)]); + h.run_steps(4); + h.get_by_label("2 objects selected"); +} + +/// A double-click on a paragraph selected with others opens it for typing (the user's ruling). +#[test] +fn a_double_click_on_a_grouped_paragraph_opens_it() { + let mut h = harness(); + marquee(&mut h); + // "Group headline" is at x 40, its baseline y 430 of 500. + let headline = at(&h, 80.0, 65.0); + crate::support::input::double_click(&mut h, headline); + h.run_steps(2); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph is open for typing"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 50801d903f..e0021aa7be 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -65,6 +65,47 @@ fn linked_text_pdf() -> Vec { ]) } +/// A 300×400 page with a picture at x 50..250, y 250..350 and a web link over its middle, at +/// x 100..200, y 280..320. +fn linked_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("q 200 0 0 100 50 250 cm /Im0 Do Q"); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /XObject << /Im0 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [100 280 200 320] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + content.as_str(), + picture, + ]) +} + +/// 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); @@ -229,16 +270,13 @@ fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool { } /// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from -/// worker threads), and returns the last cursor seen. -fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { +/// worker threads, `support::step_until`), and returns the last cursor seen. +fn wait_for_cursor(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { let mut cursor = egui::CursorIcon::Default; - for _ in 0..50 { + let _ = crate::support::step_until(h, |h| { cursor = cursor_at(h, p); - if want(cursor) { - break; - } - std::thread::sleep(std::time::Duration::from_millis(10)); - } + want(cursor) + }); cursor } @@ -344,6 +382,35 @@ fn a_drag_from_text_onto_a_link_selects_the_text() { assert!(selected.contains("example.org"), "the drag selected across the link: {selected:?}"); } +/// With the Select tool a click on a link over a picture follows the link and picks no picture; one +/// on the picture beside the link picks it. The link's click used to pick the picture under it too. +#[test] +fn a_click_on_a_link_over_a_picture_follows_it_and_picks_no_picture() { + 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 beside the link is picked"); + h.state_mut().views[0].picked_image = None; + let on_link = at(&h, 150.0, 300.0); + mouse_click(&mut h, on_link); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the link is followed"); + assert!(h.state().views[0].picked_image.is_none(), "and no picture is picked"); +} + +/// The I-beam drops over a link only where a press would go to it: with the Select tool the link's +/// hand shows there, and with the highlighter, whose drag selects the link's text, the I-beam. The +/// highlighter's I-beam used to drop over every link. +#[test] +fn the_i_beam_drops_over_a_link_only_where_the_press_goes_to_it() { + let mut h = open(linked_text_pdf(), "150", false); + let (plain, linked) = (at(&h, 200.0, 303.0), at(&h, 100.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::PointingHand, "the Select tool follows the link"); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight); + h.run_steps(2); + assert_eq!(cursor_at(&mut h, linked), egui::CursorIcon::Text, "the highlighter selects its text"); +} + /// Over two comments the tip names the one drawn on top (#3620), and a click should select that /// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in /// inspection's order, which is by top edge, and selects the one underneath. @@ -363,7 +430,7 @@ fn a_comments_tip_shows_with_every_tool_beside_its_cursor() { let mut h = open(comment_on_text_pdf(), "150", false); // Inside the rectangle comment by "Reviewer": where there is no text, and over a word. let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0)); - let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { + let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| { h.state_mut().quick_tool = tool; h.run_steps(4); let cursor = wait_for_cursor(h, p, |c| c == want); @@ -1396,6 +1463,114 @@ fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() { assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); } +/// A right-button drag from `a` to `b`, in frames of their own. +fn right_drag(h: &mut Harness<'_, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) { + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }; + h.hover_at(a); + h.run_steps(1); + h.event(button(a, true)); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.event(button(b, false)); + h.run_steps(3); +} + +// A right-button drag starts nothing a left-button drag does. egui's drag start counts every +// button, and each of these started on it. + +/// With the Rectangle tool, a right-button drag draws no rectangle. +#[test] +fn a_right_button_drag_draws_no_rectangle() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0)); + right_drag(&mut h, a, b); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no rectangle is drawn"); +} + +/// With Prepare open, a right-button drag around a field selects it by no selection rectangle. +#[test] +fn a_right_button_drag_selects_no_field() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 250.0), at(&h, 140.0, 180.0)); + right_drag(&mut h, a, b); + assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected"); +} + +/// With the Link tool, a right-button drag draws no link. +#[test] +fn a_right_button_drag_draws_no_link() { + let mut h = open(text_pdf(), "150", false); + link_tool(&mut h); + let (a, b) = (at(&h, 60.0, 150.0), at(&h, 200.0, 80.0)); + right_drag(&mut h, a, b); + assert!(h.state().link_draft.is_none() && h.state().dialog != Some(Dialog::LinkProps), "no link is drawn"); +} + +/// With the camera, a right-button drag on another page leaves the quadrilateral where it is. +#[test] +fn a_right_button_drag_leaves_the_camera_quadrilateral() { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0)); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + right_drag(&mut h, two.min + egui::vec2(40.0, 40.0), two.min + egui::vec2(120.0, 100.0)); + assert_eq!(h.state().views[0].camera.as_ref().map(|q| q.page), Some(0), "the quadrilateral stays on page 1"); +} + +/// A text selection, then `tool` and a right-button drag across more of the line. The harness, and +/// the text selected before the drag. +fn right_drag_over_a_selection(tool: QuickTool) -> (Harness<'static, PdfCraftApp>, Option) { + let mut h = open(text_pdf(), "150", false); + let (a, b) = (at(&h, 45.0, 303.0), at(&h, 120.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + mouse_drag(&mut h, a, b - a); + let selected = h.state().views[0].selected_text(); + assert!(selected.is_some(), "some text is selected"); + h.state_mut().quick_tool = tool; + h.run_steps(2); + let (c, d) = (at(&h, 150.0, 303.0), at(&h, 230.0, 303.0)); + right_drag(&mut h, c, d); + (h, selected) +} + +/// With the Select tool, a right-button drag leaves the text selected as it is. It moved the +/// selection's end to the pointer. +#[test] +fn a_right_button_drag_leaves_a_text_selection_alone() { + let (h, selected) = right_drag_over_a_selection(QuickTool::Select); + assert_eq!(h.state().views[0].selected_text(), selected, "the selection stays as it was"); +} + +/// A right-button drag with a markup tool or Redact turns no selection left from another tool into +/// a comment: its end finished the selection whichever button it was. +#[test] +fn a_right_button_drag_makes_no_mark_of_a_text_selection() { + for tool in [QuickTool::Comment(CommentTool::Highlight), QuickTool::Redact] { + let (h, _) = right_drag_over_a_selection(tool); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().info.annotations.is_empty(), "no mark with {tool:?}"); + } +} + +/// With the Select tool, a right-button drag across text selects none. +#[test] +fn a_right_button_drag_selects_no_text() { + let mut h = open(text_pdf(), "150", false); + let (a, b) = (at(&h, 45.0, 303.0), at(&h, 200.0, 303.0)); + assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived"); + right_drag(&mut h, a, b); + assert!(h.state().views[0].selected_text().is_none(), "no text is selected"); +} + /// A 300×400 page with a web link along its left edge, x 0..100, y 300..350. fn edge_link_pdf() -> Vec { pdf(&[ @@ -1418,19 +1593,42 @@ fn the_link_tools_hand_stops_at_the_pages_edge() { assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page"); } -/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies -/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on -/// the picture menu, Delete page); it used to stay where the last right-click comments saw was. +/// With Edit text a right-click goes to what lies on top under it, as with every tool: on a +/// paragraph under a comment, the comment, selected for its menu; on the paragraph beside it, the +/// paragraph, and the comment is unselected. The page's menu is anchored there either way (Paste on +/// the picture menu, Delete page). Edit text used to keep the right-clicks on its paragraphs, and +/// left the comment over them unselected. #[test] -fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() { +fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_comment() { let mut h = open(comment_on_text_pdf(), "150", false); h.state_mut().quick_tool = QuickTool::EditText; h.run_steps(4); let on_text = at(&h, 100.0, 303.0); wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); right_click(&mut h, on_text); - assert_eq!(h.state().views[0].comments.selected, None); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment over the paragraph is selected"); + assert!(h.query_by_label("Properties…").is_some(), "its menu opens"); assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page"); + // The rectangle ends at x 200; the line goes on. + let beside = at(&h, 230.0, 303.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].comments.selected, None, "the paragraph beside the comment leaves it unselected"); +} + +/// With Edit text, a right-click on added text over a comment selects no comment: what has no menu +/// gives way to a picture under it and to nothing else, as before. +#[test] +fn a_right_click_with_edit_text_on_added_text_over_a_comment_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().execute("edit.edit_text")); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [110.0, 296.0, 190.0, 311.0], text: "Added".into(), ..Default::default() }, + })); + h.run_steps(4); + let on_added = at(&h, 150.0, 303.0); + right_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None, "the comment under the added text is not selected"); } /// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1518,6 +1716,29 @@ fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() { assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected"); } +/// A comment's move ends once its button is up, though the pages missed its release, and applies +/// nothing: where it came up is not known, and the pointer has moved on. A drag elsewhere later moves +/// nothing either. The move waited for the next drag's end on the page, and took the comment there. +#[test] +fn a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up() { + let mut h = open(stacked_comments_pdf(), "150", false); + let rect = |h: &Harness<'_, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.index == 0).map(|a| a.rect) + }; + let before = rect(&h); + let bottom = at(&h, 60.0, 160.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom }); + let elsewhere = at(&h, 250.0, 100.0); + h.hover_at(elsewhere); + h.run_steps(2); + assert!(h.state().views[0].comments.gesture.is_none(), "the move is over"); + assert_eq!(rect(&h), before, "and moved nothing to where the pointer turned up"); + let blank = at(&h, 250.0, 60.0); + mouse_drag(&mut h, blank, egui::vec2(30.0, 0.0)); + assert_eq!(rect(&h), before, "nothing moved the comment"); +} + /// A click that began off the page (in the gutter beside it) is no page's: the selected comment /// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it. #[test] @@ -1591,8 +1812,8 @@ fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's"); } -/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on -/// its own paragraphs and pictures): the selected comment is unselected. +/// With Edit text a right-click on added text reaches comments, as before: the selected comment is +/// unselected. #[test] fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1673,6 +1894,198 @@ fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() { assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected"); } +/// With Prepare open, a drag from a highlight draws the selection rectangle: a highlight lets a drag +/// go, so nobody holds it and it is Prepare's. +#[test] +fn with_prepare_a_drag_from_a_highlight_draws_the_selection_rectangle() { + let mut h = open(highlighted_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + // On the highlight, beside the box; the drag ends past the box's other corner. + let (from, to) = (at(&h, 97.0, 197.0), at(&h, 130.0, 230.0)); + mouse_drag(&mut h, from, to - from); + assert!(h.state().views[0].prepare.selected.is_some(), "the rectangle selects the field inside it"); +} + +/// A right-click while the primary button is held opens the menu of the comment it went to, when that +/// is the one selected, and leaves the selection as it is. +#[test] +fn a_right_click_with_the_button_held_opens_the_selected_comments_menu() { + let mut h = open(comment_on_text_pdf(), "150", false); + // On the rectangle's left edge: an unfilled one takes a press on its border. + let edge = at(&h, 30.0, 303.0); + wait_for_cursor(&mut h, edge, |c| c != egui::CursorIcon::Default); + mouse_click(&mut h, edge); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + h.hover_at(edge); + h.run_steps(1); + h.drag_at(edge); + h.run_steps(1); + let moved = edge + egui::vec2(20.0, 0.0); + h.hover_at(moved); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: moved, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.query_by_label("Properties…").is_some(), "the comment's menu"); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the selection stays"); + h.drop_at(moved); + h.run_steps(2); +} + +/// With the page gaps hidden, a Shift-click on the row two pages share is the first page's: what it +/// selects there stays selected, and the second page leaves it alone. +#[test] +fn a_shift_click_where_two_pages_touch_keeps_what_it_selected() { + let mut h = open(bottom_line_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + assert!(h.state_mut().execute("edit.edit_text")); + 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")) + }; + // "Bottom line" runs from x 40, a few points above page 1's foot. + let seam = egui::pos2(one.min.x + 70.0, one.max.y); + assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}"); + h.hover_at(seam); + h.run_steps(2); + for pressed in [true, false] { + h.event_modifiers( + egui::Event::PointerButton { pos: seam, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::SHIFT }, + egui::Modifiers::SHIFT, + ); + } + h.run_steps(4); + assert!(h.query_by_label_contains("1 object").is_some(), "the line is selected"); +} + +/// With Prepare open on a document that can't be changed, a right-click on a field selects nothing +/// and opens the page's menu, whose field entries would only be refused. +#[test] +fn with_prepare_on_a_locked_document_a_right_click_on_a_field_selects_nothing() { + let locked = crate::editing::encrypted(comment_and_checkbox_pdf(), pdfcraft_cos::Algorithm::Aes256, "", "owner", -1 ^ 8); + let mut h = open(locked, "150", false); + // Prepare doesn't open on such a document; it stays open on a tab switched to one. + h.state_mut().left = pdfcraft_ui_egui::LeftPanel::Tool("form"); + h.state_mut().left_open = true; + h.run_steps(4); + assert!(h.state().is_preparing(), "Prepare is open"); + let id = h.state().views[0].id; + assert!(!h.state().session.get(id).unwrap().allows_modification(), "the document can't be changed"); + let on_box = at(&h, 110.0, 210.0); + right_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_none(), "no field is selected"); + assert!(h.query_by_label("Duplicate…").is_none(), "no field menu"); +} + +/// With Prepare open and a field selected, a right-click on the empty page opens the page's menu, and +/// one on the field the field's: the menu is what the right-click went to. The field's menu opened +/// wherever the page was right-clicked. +#[test] +fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + let blank = at(&h, 60.0, 300.0); + right_click(&mut h, blank); + assert!(h.query_by_label("Duplicate…").is_none(), "no field menu on the empty page"); + assert!(h.query_by_label("Marquee Zoom").is_some(), "the page's menu"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + right_click(&mut h, on_box); + assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu on the field"); +} + +/// With a Prepare field tool picked, a right-click on a field selects it and opens its menu, as with +/// Prepare's Select tool: the tool's hit leaves fields out (it draws new ones over them), but a +/// right-click reaches them. +#[test] +fn with_a_field_tool_a_right_click_on_a_field_opens_its_menu() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.prepare")); + h.run_steps(4); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(4); + let on_box = at(&h, 110.0, 210.0); + right_click(&mut h, on_box); + assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected"); + assert!(h.query_by_label("Duplicate…").is_some(), "the field's menu"); +} + +/// With Edit text, a right-click on a picture under a link is the picture's (Edit text doesn't +/// follow links): it is selected for its menu. +#[test] +fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() { + let mut h = open(linked_picture_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let on_link = at(&h, 150.0, 300.0); + right_click(&mut h, on_link); + let selected = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)); + 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] @@ -1691,8 +2104,9 @@ fn a_note_tool_click_on_added_text_starts_no_note() { assert!(h.state().views[0].comments.composer.is_none(), "no note is started"); } -/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while -/// its move is still live (its release never seen) places no new mark. +/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a press held +/// through its move and released places no new mark. (A move left over once the button is up ends: +/// `a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up`.) #[test] fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { let mut h = open(comment_on_text_pdf(), "150", false); @@ -1704,8 +2118,13 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { }; let before = marks(&h); let blank = at(&h, 150.0, 100.0); + h.hover_at(blank); + h.run_steps(1); + h.drag_at(blank); + h.run_steps(1); h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank }); - mouse_click(&mut h, blank); + h.drop_at(blank); + h.run_steps(4); assert_eq!(marks(&h), before, "no mark is placed"); } @@ -1757,11 +2176,12 @@ fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments assert!(h.query_by_label("Reply").is_none(), "not the comment's"); } -/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there -/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on -/// page 1's point, which on page 2 lies at its foot. +/// Where pages touch, a right-click on the row they share is page 1's, whatever lies there on page 2: +/// page 2's picture across its top edge opens no menu, and page 1's menu pastes on page 1, where it +/// opened. The page view's own picture scan used to open page 2's picture's menu there, whose Paste +/// centred the image on page 1's point, which on page 2 lies at its foot. #[test] -fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() { +fn paste_from_a_right_click_where_two_pages_touch_keeps_to_the_first_page() { let (mut h, seam) = touching_pages(|app| { app.system_clipboard = false; app.quick_tool = QuickTool::EditText; @@ -1774,16 +2194,18 @@ fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_poi h.run_steps(4); right_click(&mut h, seam); assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click"); - assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu"); - h.get_by_label("Paste").click(); + assert!(h.state().views[0].image_selection.is_none(), "page 2's picture is not selected for its menu"); + h.get_by_label_contains("Paste").click(); h.run_steps(3); - let pasted = { + let images = |page| { let s = h.state(); - s.session.get(s.views[0].id).unwrap().page_images(1) + s.session.get(s.views[0].id).unwrap().page_images(page) }; - assert_eq!(pasted.len(), 2, "the image is pasted on page 2"); - let r = pasted[1].rect; - assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}"); + assert_eq!(images(1).len(), 1, "nothing is pasted on page 2"); + let pasted = images(0); + assert_eq!(pasted.len(), 1, "the image is pasted on page 1"); + let r = pasted[0].rect; + assert!((r[1] + r[3]) / 2.0 < 100.0, "at page 1's foot, where its menu opened: {r:?}"); } /// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it