From 97094a1c86338eb39fd849c94ce3680f5f22df92 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 19:40:10 +0100 Subject: [PATCH 01/24] refactor(ui): crop mode is a hit of its picture, and the picture's keys are the app's Crop mode held every press on its page through a hit with no point test, so its claim won the cursor on every visible page (its Default arrow over the next page's paragraphs), and its own grip test at the drag's start and its cursor test used different reaches (10 and 8 px). - `edit_text_ui::crop_hit` says what of the picture being cropped lies under a point: a grip of the box kept, the box itself, or the rest of its page (`PicturePart::Crop`, on `Layer::Editor`), and the hand over its Done, Cancel and Remove Crop controls, past the page's edge too. A drag takes what its press was on (`ImageCx.press`), and the cursor is the hit's: one reach for both. - Delete and Backspace on a selected picture, and crop mode's Enter and Escape, move to the app's key handling (`edit_text_ui::picture_keys`), which no key reaches under a dialog. Delete works wherever the pointer is (the user's ruling: before, it did nothing over a paragraph). Escape while a button is down is still the press's: it cancels a crop drag, and crop mode stays. - #5900's `crop_controls_contain` (the right-click check for #5835) measures with `crop_frames` and the grips' reach, so the crop's frame and reach have one source; its `crop_bounds` was `crop_frames`' frame. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/keys.rs | 2 + crates/ui-egui/src/canvas/page_input.rs | 10 +- crates/ui-egui/src/canvas/page_router.rs | 15 ++ crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 188 +++++++++++------- crates/ui-egui/tests/it/editing.rs | 117 +++++++++++ 6 files changed, 255 insertions(+), 79 deletions(-) diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index c014453806..2fc44da6f2 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -66,6 +66,8 @@ impl PdfCraftApp { { object_ui::nudge_keys(ctx, &mut self.views[i], doc); object_ui::delete_keys(ctx, &mut self.views[i]); + let held = ctx.input(|input| input.pointer.any_down()); + edit_text_ui::picture_keys(ctx, &mut self.views[i], doc, held); } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 03bd3e807d..f8399e3c3b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -179,6 +179,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut ptr, page: i, info, + press, allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), }; let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); @@ -458,12 +459,9 @@ impl PageHits<'_> { images, clip, } = *self; - // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). - if tool == QuickTool::EditText - && can_modify - && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) - { - return vec![crop]; + // Crop mode holds every press on its page (`edit_text_ui::crop_hit`). + if tool == QuickTool::EditText && can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == page && s.crop.is_some()) { + return crate::edit_text_ui::crop_hit(xf, page, info, images, view, p).into_iter().collect(); } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index e3b88d1a2c..45162666ce 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -49,6 +49,21 @@ pub(crate) enum PicturePart { Corner(usize), /// The selected picture's rotate knob: a drag turns it. Knob, + /// The picture being cropped, which holds every press on its page. + Crop(CropPart), +} + +/// What of a picture being cropped a hit is on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CropPart { + /// A grip of the box kept, in `crop_handles` order (four corners, then the top, right, bottom + /// and left edge midpoints): a drag moves that side or corner. + Grip(usize), + /// The box kept: a drag moves it over the picture. + Keep, + /// The rest of the page, its Done and Cancel controls among it: a press there is crop mode's, + /// and moves nothing. + Page, } /// What of the objects a hit is on. diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f520a91f86..c1a5a6557c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -24,7 +24,7 @@ pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, - quad_contains, user_to_screen, + picture_keys, quad_contains, user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index d3c76f94e2..c0c52001a5 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -2,7 +2,7 @@ use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, use crate::canvas::{ImageObb, PainterExt, image_screen_rect}; use crate::image_engine::{CROP_ACCENT, apply_crop, apply_resize, apply_resize_proportional, draw_handle, draw_rule_of_thirds}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner, PicturePart}; +use crate::page_router::{CropPart, Hit, Owner, PagePress, PicturePart}; /// What the pointer and the page are, for the image tools on one page. #[derive(Clone, Copy)] @@ -13,6 +13,8 @@ pub(crate) struct ImageCx<'a> { pub ptr: &'a PagePointer, pub page: usize, pub info: &'a DocInfo, + /// What the press the pages hold is to this page. + pub press: PagePress, pub allow_interaction: bool, } @@ -116,14 +118,29 @@ pub struct CropMode { pub keep: [f64; 4], /// A drag in progress: the grip (0–7 handles, 8 inside), where it started and the box then. pub(crate) drag: Option<(usize, Pos2, [f64; 4])>, + /// Where its Done, Cancel and Remove Crop controls were drawn last (screen): the hand shows + /// over them ([`crop_hit`]). + pub(crate) controls: Vec, } impl CropMode { pub fn new(keep: [f64; 4]) -> Self { - Self { keep, drag: None } + Self { keep, drag: None, controls: Vec::new() } } } +/// How far from a crop grip a press still takes it, and the pointer shows its cursor (screen). +const CROP_GRIP_REACH: f32 = 10.0; + +/// The picture `image` being cropped to `keep`, on screen: its whole frame, and the box kept +/// within it. `None` when either has no area. +fn crop_frames(xf: &PageXform, info: &DocInfo, page: usize, image: &pdfcraft_engine::PageImage, keep: [f64; 4]) -> Option<(Rect, Rect)> { + let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); + let b = if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) }; + let k = xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + (b.is_finite() && b.is_positive() && b.size().is_finite() && k.is_finite() && k.is_positive()).then_some((b, k)) +} + pub(crate) use pdfcraft_content::Matrix; /// The displayed crop frame, expressed in page user space, rather than the full image placement. @@ -246,12 +263,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) } -fn crop_bounds(image: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect { - let full = xf.user_rect(info, page, image.rect.map(|v| v as f32)); - if full.is_positive() { full } else { xf.user_rect(info, page, image.visible_rect.unwrap_or(image.rect).map(|v| v as f32)) } -} - -/// Whether `p` is on the crop box or one of its handles (#5835). The Done, Cancel and Remove +/// Whether `p` is on the crop box or one of its grips (#5835). The Done, Cancel and Remove /// Crop pills are widgets of their own, so they take their clicks before the canvas does. pub(crate) fn crop_controls_contain( image: &pdfcraft_engine::PageImage, @@ -261,35 +273,35 @@ pub(crate) fn crop_controls_contain( page: usize, p: Pos2, ) -> bool { - let b = crop_bounds(image, xf, info, page); - let k = xf.user_rect(info, page, mode.keep.map(|v| v as f32)).intersect(b); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { - return false; - } - k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < 10.0) + crop_frames(xf, info, page, image, mode.keep) + .is_some_and(|(_, k)| k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < CROP_GRIP_REACH)) } /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` -/// (the pointer and keys are the crop's while it lasts). +/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, press, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; - let b = crop_bounds(image, xf, info, page); - let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); - let k = keep_screen(mode.keep); - if !b.is_finite() || !b.is_positive() || !b.size().is_finite() || !k.is_finite() || !k.is_positive() { + let Some((b, k)) = crop_frames(xf, info, page, image, mode.keep) else { mode.drag = None; return true; - } + }; + let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); + // A drag takes what its press was on ([`crop_hit`]). if allow_interaction && resp.drag_started() && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press { - let grip = crop_handles(k).iter().position(|h| h.distance(o) < 10.0).or_else(|| k.contains(o).then_some(8)); + let grip = match part { + CropPart::Grip(g) => Some(g), + CropPart::Keep => Some(8), + CropPart::Page => None, + }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); } @@ -352,7 +364,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e // Crop handles for (idx, h) in crop_handles(k).iter().enumerate() { let is_active = mode.drag.as_ref().is_some_and(|d| d.0 == idx); - let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < 10.0); + let is_hovered = hover_pos.is_some_and(|p| p.distance(*h) < CROP_GRIP_REACH); draw_handle(painter, *h, is_active, is_hovered, true, CROP_ACCENT); } @@ -385,6 +397,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let done = Rect::from_min_size(Pos2::new(right - done_w, y), egui::vec2(done_w, 22.0)); let cancel = Rect::from_min_size(Pos2::new(done.min.x - spacing - cancel_w, y), egui::vec2(cancel_w, 22.0)); let reset = reset_w.map(|w| Rect::from_min_size(Pos2::new(cancel.min.x - spacing - w, y), egui::vec2(w, 22.0))); + mode.controls = [Some(done), Some(cancel), reset].into_iter().flatten().collect(); let done_id = ui.scope_id().with("crop_pill_done").with(page).with(i); let cancel_id = ui.scope_id().with("crop_pill_cancel").with(page).with(i); @@ -430,26 +443,11 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e neutral_pill(r, response, tl!("Remove Crop")); } - if let Some(p) = hover_pos { - let icon = if done_resp.hovered() - || cancel_resp.hovered() - || reset_resp.as_ref().is_some_and(|r| r.hovered()) - || done.contains(p) - || cancel.contains(p) - || reset.is_some_and(|r| r.contains(p)) - { - egui::CursorIcon::PointingHand - } else if let Some(&g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0).and_then(|g| GRIPS.get(g)) { - g.cursor_icon() - } else if k.contains(p) { - if mode.drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab } - } else { - egui::CursorIcon::Default - }; - view.cursor.claim(Layer::Editor, icon); + // The cursor is the hit's ([`crop_hit`]), and a grip's or the box's being dragged. + if let Some((grip, ..)) = mode.drag { + view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon())); } - let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input(); let done_clicked = allow_interaction && (done_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| done.contains(p)))); let cancel_clicked = allow_interaction && (cancel_resp.clicked() || (resp.clicked() && hover_pos.is_some_and(|p| cancel.contains(p)))); let reset_clicked = allow_interaction @@ -459,27 +457,60 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e if reset_clicked { mode.keep = if image.has_editor_crop() { image.rect } else { initial_keep }; mode.drag = None; - } else if done_clicked || keys(egui::Key::Enter) { - let keep = mode.keep; - if let Some(s) = view.image_selection.as_mut() { - s.crop = None; - } - let is_full = same_rect(keep, image.rect); - if is_full { - if image.has_editor_crop() { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RemoveCrop }); - } - } else if image.visible_rect != Some(keep) { - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Crop(keep) }); - } - } else if (cancel_clicked || keys(egui::Key::Escape)) - && let Some(s) = view.image_selection.as_mut() - { + } else if done_clicked { + apply_crop_mode(view, image); + } else if cancel_clicked && let Some(s) = view.image_selection.as_mut() { s.crop = None; } true } +/// Done in crop mode: it ends, and the picture `image` of the selection keeps the box kept (or +/// all of itself again). +fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) { + let Some(s) = view.image_selection.as_mut() else { return }; + let Some(keep) = s.crop.take().map(|mode| mode.keep) else { return }; + let (page, index) = (s.page, s.index); + if same_rect(keep, image.rect) { + if image.has_editor_crop() { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::RemoveCrop }); + } + } else if image.visible_rect != Some(keep) { + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Crop(keep) }); + } +} + +/// The selected picture's keys, from the app's own (none reach it under a dialog): Delete or +/// Backspace removes it, wherever the pointer is, unless it is being dragged or cropped. In crop +/// mode Enter applies the crop and Escape leaves it, though not while a button is down (`held`): +/// Escape then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode +/// stays. Nothing while a text field has the keyboard. +pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, doc: &pdfcraft_engine::Document, held: bool) { + if ctx.egui_wants_keyboard_input() { + return; + } + let Some(s) = view.image_selection.as_ref() else { return }; + let (page, index) = (s.page, s.index); + let key = |k: egui::Key| ctx.input(|i| i.key_pressed(k)); + // The page's pictures are read only when a key asks for one. + let image = || doc.page_images(page).into_iter().nth(index); + if s.crop.is_some() { + if key(egui::Key::Enter) { + if let Some(image) = image() { + apply_crop_mode(view, &image); + } + } else if !held + && key(egui::Key::Escape) + && let Some(s) = view.image_selection.as_mut() + { + s.crop = None; + } + } else if !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image().is_some() { + view.image_selection = None; + view.pending_edit = Some(Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Delete }); + } +} + /// The page raster's picture of the box `from`, stretched into `to`, over blanks at `blank` (where /// the screen may still show the picture). fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::TextureId, blank: &[Rect], from: Rect, to: Rect) { @@ -541,12 +572,33 @@ pub(crate) fn handle_hit( Some(Hit::new(picture(PicturePart::Corner(grip)), Layer::Handle, obb.handle_cursor(handle))) } -/// The picture being cropped on `page`: crop mode owns every press there, the controls beside its -/// frame included, so the paragraphs under it and the empty page's marquee never take a crop -/// button's press before the picture does. `None` when no picture on `page` is being cropped. -pub(crate) fn crop_hit(view: &DocView, page: usize) -> Option { - let selection = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())?; - Some(Hit::new(Owner::Picture { index: selection.index, part: PicturePart::Body }, Layer::Handle, egui::CursorIcon::Default)) +/// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the +/// controls beside its frame included, so the paragraphs under it and the empty page's marquee +/// never take a crop button's press before the picture does. A grip of the box kept (within +/// [`CROP_GRIP_REACH`]), the box itself, or the rest of the page; and the controls past the page's +/// edge, where they show the hand. `None` when no picture on `page` is being cropped, or `p` is off +/// the page and its controls. +pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { + let s = view.image_selection.as_ref().filter(|s| s.page == page)?; + let mode = s.crop.as_ref()?; + let control = mode.controls.iter().any(|r| r.contains(p)); + if !control && !xf.rect.contains(p) { + return None; + } + let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); + if control { + return crop(CropPart::Page, egui::CursorIcon::PointingHand); + } + let Some((_, k)) = images.get(s.index).and_then(|image| crop_frames(xf, info, page, image, mode.keep)) else { + return crop(CropPart::Page, egui::CursorIcon::Default); + }; + if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH) { + return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + } + if k.contains(p) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } + crop(CropPart::Page, egui::CursorIcon::Default) } /// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the @@ -628,7 +680,7 @@ pub fn image_menu_id() -> egui::Id { /// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. #[allow(clippy::cognitive_complexity)] pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool { - let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction, .. } = *cx; let boxes: Vec = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect(); let painter = ui.painter(); let hover_pos = ptr.pos; @@ -949,14 +1001,6 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage view.cursor.claim(Layer::Drag, cursor_icon); return true; } - if allow_interaction - && ui.input(|inp| inp.key_pressed(egui::Key::Delete) || inp.key_pressed(egui::Key::Backspace)) - && !ui.ctx().egui_wants_keyboard_input() - { - view.image_selection = None; - view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Delete }); - return true; - } } if !allow_interaction { return false; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c4231d0f69..5be20ce51b 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7230,6 +7230,123 @@ fn cropping_a_picture_with_its_handles() { assert!(h.state().views[0].image_selection.as_ref().is_none_or(|s| s.crop.is_none())); } +/// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0 +/// selected (and being cropped, with `crop`). +fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(3); + let id = h.state().views[0].id; + let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = crop.then(|| pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + h +} + +/// The screen point at PDF (x, y) of `page` of [`picture_pages`]. +fn page_at(h: &Harness<'static, PdfCraftApp>, page: usize, x: f32, y: f32) -> egui::Pos2 { + let r = h.state().views[0].page_screen_rect(page).expect("both pages on screen"); + egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height()) +} + +fn cursor_over(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// Crop mode holds every press on its page, and its cursor stays there: the grips show theirs and +/// the box kept the hand that drags it, and on the next page the pointer shows what a press there +/// takes (a paragraph's move arrow). Crop mode's cursor used to win on every page. +#[test] +fn crop_modes_cursor_stays_on_its_page() { + let mut h = picture_pages(true); + // The visible picture is x 30..160, y 170..250. + for (page, x, y, cursor, what) in [ + (0, 160.0, 170.0, egui::CursorIcon::ResizeNwSe, "a corner grip"), + (0, 95.0, 210.0, egui::CursorIcon::Grab, "the box kept"), + (0, 65.0, 134.0, egui::CursorIcon::Default, "crop mode holds its page's paragraphs"), + (1, 65.0, 134.0, egui::CursorIcon::Move, "the next page's paragraph"), + ] { + let p = page_at(&h, page, x, y); + assert_eq!(cursor_over(&mut h, p), cursor, "{what}"); + } +} + +/// A press on a crop grip a little outside the picture drags that grip, and Escape in the middle +/// of the drag cancels it: the box goes back and crop mode stays. Escape once the button is up +/// leaves crop mode. +#[test] +fn escape_during_a_crop_drag_keeps_crop_mode() { + let mut h = picture_pages(true); + let before = h.state().views[0].image_selection.as_ref().unwrap().crop.as_ref().unwrap().keep; + let grip = page_at(&h, 0, 160.0, 170.0) + egui::vec2(5.0, 5.0); + h.hover_at(grip); + h.run_steps(1); + h.drag_at(grip); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(grip - egui::vec2(10.0, 10.0) * k as f32); + h.run_steps(1); + } + let keep = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep(&h).is_some_and(|k| k[2] < before[2] - 5.0 && k[1] > before[1] + 5.0), "the grip outside the picture is dragged"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "Escape cancels the drag, and crop mode stays"); + h.drop_at(grip - egui::vec2(40.0, 40.0)); + h.run_steps(2); + assert_eq!(keep(&h), Some(before), "the cancelled drag's release crops nothing"); + h.key_press(Key::Escape); + h.run_steps(2); + assert_eq!(keep(&h), None, "Escape leaves crop mode"); + assert!(!dirty(&h)); +} + +/// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the +/// app's, not the pointer's. +#[test] +fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() { + let mut h = picture_pages(false); + let caption = page_at(&h, 0, 65.0, 134.0); + assert_eq!(cursor_over(&mut h, caption), egui::CursorIcon::Move, "on the caption's paragraph"); + h.key_press(Key::Delete); + h.run_steps(3); + let id = h.state().views[0].id; + assert!(h.state().session.get(id).unwrap().page_images(0).is_empty(), "the picture is deleted"); + assert!(h.state().views[0].image_selection.is_none()); +} + +/// With a dialog open, Delete is the dialog's: the selected picture stays, and so does crop mode +/// under Escape and Enter. +#[test] +fn a_dialog_keeps_its_delete_while_a_picture_is_selected() { + let mut h = picture_pages(false); + let on_picture = page_at(&h, 0, 95.0, 210.0); + h.hover_at(on_picture); + h.run_steps(1); + assert!(h.state_mut().execute("app.preferences")); + h.run_steps(3); + for key in [Key::Delete, Key::Backspace] { + h.key_press(key); + h.run_steps(3); + } + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().page_images(0).len(), 1, "the picture stays under the dialog"); + assert!(h.state().views[0].image_selection.is_some()); + assert_eq!(h.state().dialog, Some(Dialog::Preferences)); +} + /// Organize pages has Acrobat's panel whenever the grid shows, however it was opened, and the /// panel's Close ends Organize: the grid closes and All tools comes back. #[test] From 6789b2595600a4debef943f5a259338b587f06dc Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 23:51:34 +0100 Subject: [PATCH 02/24] =?UTF-8?q?refactor(ui):=20a=20press=20with=20Shift?= =?UTF-8?q?=20or=20=E2=8C=98=20in=20Edit=20text=20is=20the=20objects',=20b?= =?UTF-8?q?y=20their=20hit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `object_ui` took a Shift- or ⌘-press before anyone else, whoever owned it, through its own inventory test at the press point, and declined a plain press where that test found an object the router didn't (just outside a picture's edge), so such a press started nothing at all. - The page view reads the press's modifiers from `PagePointer` (`modifiers`: those held at the frame's first primary press). With Edit text, a press that adds (Shift, Ctrl or ⌘) is the objects' whole: `object_ui::modified_hit` gives `Objects { Toggle(target) }` for the object under it, or none (an area selection that adds), `Held` while text being typed is open, and `Refused` on a page whose objects can't be selected together. Only the press asks: hover and cursors are as before. - `object_ui` is told what the press is to its page (`PagePress`) and acts on its own parts; a press nobody holds starts the area selection. The inventory test and its decline go, and so does `blocked_page`: the held press keeps the editor open until the button is up through its capture. - A plain press beside a picture, within the 2 px the objects are found in but outside the picture, now starts an area selection. - The guard: `object_ui::input` no longer reads the router. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 40 +++- crates/ui-egui/src/canvas/page_pointer.rs | 20 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 15 +- crates/ui-egui/src/canvas/page_router.rs | 10 + crates/ui-egui/src/tools/object_ui.rs | 208 ++++++++++-------- crates/ui-egui/tests/it/object_selection.rs | 53 +++++ 6 files changed, 234 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f8399e3c3b..7c715bed8f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -91,7 +91,15 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut clip: ui.clip_rect(), }; if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); + // 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 + && crate::object_ui::adds(ptr.modifiers) + && !resp.context_menu_opened() + && !egui::Popup::is_any_open(ui.ctx()); + let objects = adds.then(|| view.objects.inventory(doc, i)); + let at = hits.at(view, o, objects.as_ref()); view.router.press(i, at, tool); } // What this page has to do with the press: its owner's, the active tool's, or another page's. @@ -99,7 +107,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // 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(); + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -135,7 +143,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; 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); + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, 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 @@ -217,7 +225,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut /// 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()); @@ -411,9 +419,10 @@ 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) + /// 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 @@ -429,17 +438,24 @@ impl PageHits<'_> { { return Some(press.owner); } - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + top(self.hits(view, p, true, None), QuickTool::Select).map(|h| h.owner) } /// 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. /// 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, @@ -459,6 +475,10 @@ impl PageHits<'_> { images, clip, } = *self; + // 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`). if tool == QuickTool::EditText && can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == page && s.crop.is_some()) { return crate::edit_text_ui::crop_hit(xf, page, info, images, view, p).into_iter().collect(); diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 38b028de24..e847009dd0 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,16 @@ 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, } 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 +63,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 +79,9 @@ 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 }; } - Self { pos, origin: press_origin, edges, released, primary_down, any_down } + Self { pos, origin: press_origin, edges, released, primary_down, any_down, modifiers } } /// Where the pointer is, when it is inside `r`. @@ -108,6 +112,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..ee30ca98c6 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -573,7 +573,7 @@ const ROUTER_READS: [&str; 6] = /// 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] = [ +const ROUTER_READERS: [(&str, &str, &str); 7] = [ ( "canvas/page_input.rs", "page_tool_input", @@ -585,7 +585,6 @@ const ROUTER_READERS: [(&str, &str, &str); 8] = [ ("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)"), ]; /// What ignores the press the pages hold until the button is up. @@ -597,7 +596,7 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// 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] = [ +const PAGE_HIT_CALLS: [&str; 13] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", @@ -609,6 +608,7 @@ const PAGE_HIT_CALLS: [&str; 12] = [ "edit_text_ui::picture_handle_hit(", "edit_text_ui::crop_hit(", "object_ui::hit(", + "object_ui::modified_hit(", "content_ui::hit(", ]; @@ -695,12 +695,9 @@ fn page_modules_act_on_a_press_only_through_the_router() { "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"), - ] { + 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")] + { 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) { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 45162666ce..06e32a272e 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -10,6 +10,7 @@ //! the active tool's, or none of its business. use egui::CursorIcon; +use pdfcraft_engine::ObjectTarget; use crate::QuickTool; use crate::page_cursor::Layer; @@ -73,6 +74,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. diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index 67a077bc7b..bc6524c53a 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -9,7 +9,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; -use crate::page_router::{Hit, ObjectsPart, Owner}; +use crate::page_router::{Hit, ObjectsPart, Owner, PagePress}; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -24,7 +24,6 @@ pub(crate) struct ObjectSelection { pending: 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,10 +39,9 @@ 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 { @@ -56,7 +54,6 @@ impl ObjectSelection { self.input_blocked = blocked; if blocked { self.gesture = None; - self.blocked_page = None; } } @@ -65,7 +62,6 @@ impl ObjectSelection { self.selected.clear(); self.gesture = None; self.pending = None; - self.blocked_page = None; self.select_paragraph = None; } @@ -139,7 +135,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 +149,9 @@ 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 - }) +/// 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 { @@ -195,36 +184,84 @@ 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, .. } = cx; if view.objects.input_blocked { return true; } if resp.context_menu_opened() || egui::Popup::is_any_open(ui.ctx()) { 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; } 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); @@ -285,70 +322,63 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) - if !pressed { 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 }) if !resp.double_clicked() => { + 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); diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index fc2a8e4fe7..54dcfa5f33 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -385,3 +385,56 @@ 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"); +} From cbcf79acc7d44017e6f777eb5f6d1ca92c2a8a19 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 19:50:16 +0100 Subject: [PATCH 03/24] refactor(ui): tell Edit text, Add text, pictures and added content whether the press is theirs The Edit text group still asked the router for itself and passed answers from one module to the next: `others_own_pointer`, `text_used`, `over_source`, `source_blocked` and the pictures' `allow_interaction` decided who acted, so what the pointer happened to be over at the release, or an earlier module's answer, could take a click from the module its press was on. - Paragraphs (`ParagraphCx`), pictures (`ImageCx`) and added content (`ContentCx`) are told the press (`PagePress`) and Edit text's gate (its input blocked by a dialog, the palette, another window or tab, as before), and act on their own presses; the paragraph outline comes from the top hit under the pointer (`hovered`), hidden while the objects selected together or added content being moved hold the page. The flags go, and the three modules no longer read the router. - Add text acts on the paragraph its press was on, or the one under the pointer; its click on a paragraph refuses a blocked draft and finishes the one being typed in the paragraph's own act. - Clicks act where they were pressed (the user's ruling): a paragraph opens though the button came up just past its box, added text is selected, and opened for typing, by the item its press was on, and Add text's new box starts where it was pressed (a press beside a paragraph released inside it starts a box; it placed nothing). - The picture's grips and rotate knob lose their egui widgets: a drag takes what the press was on. A press past a page's edge is the page's that has something under it there (the knob of a picture filling its page), so the knob still turns the picture there. - Edit text's right-click goes to what lies on top under it, as every tool's does (ruling C): a picture is selected for its menu through `edit_text_ui::picture_context`, and a comment over a paragraph or a picture gets its own menu. The page view's own picture scan goes. - #5900's part of that scan (a right-click on the picture being cropped, its box or its grips keeps crop mode, over a picture on top of it too) is the crop hit's: `CropPart::Cut`, the picture outside the box kept, shown or cut away, joins the grips and the box, so a right-click there keeps crop mode. Its menu check stays for now (the menu commit takes it). Test: `a_right_click_on_the_part_a_crop_cuts_away_keeps_crop_mode`, seen failing without `Cut`. - With the Edit panel open, the tools that don't pick added content up still select added text by its own test at the pointer, as before: a press-time hit would take the press from the stamps and the other tools that place on any press. - `main`'s press-handler check guards nothing now, and goes; rule (e) (`ROUTER_READERS`) keeps the modules from reading the router again. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 57 +----- crates/ui-egui/src/canvas/page_input.rs | 116 ++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 51 +---- crates/ui-egui/src/canvas/page_router.rs | 11 +- crates/ui-egui/src/tools/content_ui.rs | 113 +++++++---- crates/ui-egui/src/tools/edit_text_ui.rs | 69 +++++-- .../ui-egui/src/tools/edit_text_ui/image.rs | 184 +++++++----------- crates/ui-egui/src/tools/object_ui.rs | 11 ++ crates/ui-egui/tests/it/editing.rs | 139 +++++++++++++ crates/ui-egui/tests/it/pointer.rs | 45 +++-- 10 files changed, 454 insertions(+), 342 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index be2dd97d53..d65d20051d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,8 +1604,6 @@ fn draw_pages( prefs, preparing, selection_wheel, - ppp, - rects, single_paging, tool, unobstructed, @@ -1674,54 +1672,9 @@ 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 keeps its menu, off the pages as on them. 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; - } } let mut frame = PageFrame { env: *env, @@ -1759,9 +1712,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, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 7c715bed8f..97aad93100 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -37,8 +37,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, @@ -90,24 +91,31 @@ 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)) { + 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) && !resp.context_menu_opened() && !egui::Popup::is_any_open(ui.ctx()); let objects = adds.then(|| view.objects.inventory(doc, i)); let at = hits.at(view, o, objects.as_ref()); - view.router.press(i, at, tool); + // A press is the page's it lies on, or the one with something under it past its edge (the + // selected picture's rotate knob and grips). + if xf.rect.contains(o) || !at.is_empty() { + view.router.press(i, at, tool); + } } // 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; + // 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); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -117,7 +125,17 @@ 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)); + let right = frame.right_click_on(xf.rect); + 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, part }) if part != crate::page_router::PicturePart::Crop(crate::page_router::CropPart::Page) => Some(index), + _ => None, + }; + crate::edit_text_ui::picture_context(ui.ctx(), 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). @@ -143,58 +161,38 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) }; + // 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()); let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, press, 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; - } - let on_content = !on_objects && 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 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, - press, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; + let on_content = 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; } @@ -340,7 +338,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. @@ -351,13 +350,10 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu 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(), }; diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index ee30ca98c6..de4e0fb9ab 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -570,21 +570,13 @@ const ROUTER_READS: [&str; 6] = ["router.press_for(", "router.owner(", "router.keeps_drag(", "PageRouter::press_for(", "PageRouter::owner(", "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); 7] = [ - ( - "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", - ), +/// page and tells each module what the press is to its page (`PagePress`); 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); 4] = [ + ("canvas/page_input.rs", "page_tool_input", "the page view: what the press is to each page (`press_for`)"), ("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)"), ]; /// What ignores the press the pages hold until the button is up. @@ -594,8 +586,8 @@ 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). +/// The module hit tests, called by path (HIT_TESTERS says who may): an entry no page module calls +/// any more fails the test. const PAGE_HIT_CALLS: [&str; 13] = [ "link_ui::hit(", "prepare::hit(", @@ -612,17 +604,6 @@ const PAGE_HIT_CALLS: [&str; 13] = [ "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 @@ -694,16 +675,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")] - { - 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( @@ -774,13 +745,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 06e32a272e..1302901136 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -62,6 +62,9 @@ pub(crate) enum CropPart { 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, @@ -146,7 +149,8 @@ pub(crate) enum PagePress { /// 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, } @@ -183,8 +187,9 @@ impl PageRouter { 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) { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 5b5154f90a..be6b1a2734 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,8 @@ 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)>, undrawable: UndrawableMemo, layout: Option<(AddedText, f32, f64, [f64; 4])>, } @@ -116,6 +118,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 +380,33 @@ 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 every tool ([`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; + if !gate { + return false; + } let mut consumed = false; let pointer = ptr.pos; let cv = &mut view.content; @@ -412,19 +436,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 +467,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 +480,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. + // 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 resp.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 +530,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 diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index c1a5a6557c..ed24d45839 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -14,7 +14,7 @@ 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; @@ -24,7 +24,7 @@ pub(crate) use background::TextBackground; pub use image::{CropMode, DragTarget, ImageAction, ImageDrag, ImageHandle, ImageSelection, image_menu_id}; pub(crate) use image::{ ImageCx, Matrix, body_hit as picture_hit, crop_controls_contain, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, - picture_keys, quad_contains, user_to_screen, + 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,8 +766,8 @@ 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() @@ -767,18 +783,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 c0c52001a5..de36b80f5c 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -15,7 +15,8 @@ pub(crate) struct ImageCx<'a> { pub info: &'a DocInfo, /// What the press the pages hold is to this page. pub press: PagePress, - pub allow_interaction: bool, + /// 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). @@ -281,7 +282,7 @@ pub(crate) fn crop_controls_contain( /// (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, press, 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; @@ -292,7 +293,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); // A drag takes what its press was on ([`crop_hit`]). - if allow_interaction + if gate && resp.drag_started() && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press @@ -300,7 +301,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e let grip = match part { CropPart::Grip(g) => Some(g), CropPart::Keep => Some(8), - CropPart::Page => None, + CropPart::Cut | CropPart::Page => None, }; if let Some(g) = grip { mode.drag = Some((g, o, mode.keep)); @@ -406,10 +407,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()); @@ -448,9 +449,9 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e view.cursor.claim(Layer::Drag, GRIPS.get(grip).map_or(egui::CursorIcon::Grabbing, |g| g.cursor_icon())); } - 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)))); @@ -575,9 +576,9 @@ pub(crate) fn handle_hit( /// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the /// controls beside its frame included, so the paragraphs under it and the empty page's marquee /// never take a crop button's press before the picture does. A grip of the box kept (within -/// [`CROP_GRIP_REACH`]), the box itself, or the rest of the page; and the controls past the page's -/// edge, where they show the hand. `None` when no picture on `page` is being cropped, or `p` is off -/// the page and its controls. +/// [`CROP_GRIP_REACH`]), the box itself, the picture outside it (its frame shown or its whole frame), +/// or the rest of the page; and the controls past the page's edge, where they show the hand. `None` +/// when no picture on `page` is being cropped, or `p` is off the page and its controls. 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()?; @@ -589,14 +590,19 @@ pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pd if control { return crop(CropPart::Page, egui::CursorIcon::PointingHand); } - let Some((_, k)) = images.get(s.index).and_then(|image| crop_frames(xf, info, page, image, mode.keep)) else { - return crop(CropPart::Page, egui::CursorIcon::Default); - }; - if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH) { - return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + let Some(image) = images.get(s.index) else { return crop(CropPart::Page, egui::CursorIcon::Default) }; + if let Some((_, k)) = crop_frames(xf, info, page, image, mode.keep) { + if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH) { + return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); + } + if k.contains(p) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } } - if k.contains(p) { - return crop(CropPart::Keep, egui::CursorIcon::Grab); + let shown = ImageObb::from_image(image, xf, info, page); + let whole = ImageObb::from_matrix(image.matrix, xf, info, page); + if quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) { + return crop(CropPart::Cut, egui::CursorIcon::Default); } crop(CropPart::Page, egui::CursorIcon::Default) } @@ -677,10 +683,12 @@ 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; @@ -776,56 +784,22 @@ 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. + if gate + && resp.drag_started() + && let Some(o) = ptr.origin + && let PagePress::Mine(Owner::Picture { index, part }) = press + && index == i + && let Some(s) = view.image_selection.as_mut() + { + 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), @@ -833,11 +807,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 => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); } + PicturePart::Crop(_) => {} } } @@ -1002,50 +976,36 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - 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; + // A click selects the picture its press was on; its cursor is the hit's. + let owner = match press { + PagePress::Mine(Owner::Picture { index, .. }) => Some(index), + _ => None, }; - 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); - } - } - 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 { + if gate + && resp.clicked() + && let Some(index) = owner + { view.image_selection = Some(ImageSelection::at(page, index)); } - if resp.secondary_clicked() { - ui.ctx().data_mut(|d| d.insert_temp(menu_key, true)); + owner.is_some() +} + +/// A right-click on `page` that went to its picture `index` (on it, a grip of it, or the box kept +/// while it is cropped) selects it, keeping it as it is if it was selected, and opens the picture's +/// menu; one that went to anything else (`None`) unselects the picture selected and closes the menu. +pub(crate) fn picture_context(ctx: &egui::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)); + } + ctx.data_mut(|d| d.insert_temp(image_menu_id(), true)); + } + None => { + view.image_selection = None; + ctx.data_mut(|d| d.insert_temp(image_menu_id(), false)); + } } - true } /// The right-click context menu for a selected image. diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index bc6524c53a..e504aaecf1 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -48,6 +48,17 @@ impl ObjectSelection { 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) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 5be20ce51b..f8c56ae6ab 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7347,6 +7347,117 @@ fn a_dialog_keeps_its_delete_while_a_picture_is_selected() { 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"); +} + +/// 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] @@ -8138,6 +8249,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/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 50801d903f..003cf2d0fa 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1418,19 +1418,25 @@ 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_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 a Prepare field tool a right-click is the tool's, as before: a comment under it is not @@ -1591,8 +1597,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); @@ -1757,11 +1763,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 +1781,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 From 607635c15775c0a706c5e62d9f0ecbba30bb5a94 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 19:56:13 +0100 Subject: [PATCH 04/24] fix(ui): the page menu is the menu of what the right-click went to The page's menu chose its entries from the selections, in a fixed order: a selected Prepare field's menu, then a link's, then the picture's, then a comment's. A selection left from before outranked what was just right-clicked, so with a field selected, a right-click on the empty page (or on a comment) opened the field's menu. The right-click remembers what it went to (`DocView.menu_for`, from the routed hits), and the menu shows that owner's entries when it has a menu, or else the page's (the user's ruling). Add text's right-click is the page's, as before; while a press is held only a link takes its menu. The picture menu's flag and its geometric test go with it. #5900's menu check (no image menu over the picture being cropped, #5835) is the routed owner's too: a right-click that went to the crop (its picture, box or grips) opens no menu, and `crop_contains_point` and `crop_controls_contain`, its second point test, go. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 78 +++++++------------ crates/ui-egui/src/canvas/page_input.rs | 9 ++- crates/ui-egui/src/tools/edit_text_ui.rs | 6 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 39 ++-------- crates/ui-egui/tests/it/pointer.rs | 21 +++++ 5 files changed, 67 insertions(+), 86 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d65d20051d..6855850615 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)] @@ -668,6 +652,9 @@ pub struct DocView { pub(crate) cursor: crate::page_cursor::PageCursor, /// Who the press the pages hold belongs to. pub(crate) router: crate::page_router::PageRouter, + /// What the last right-click on a page went to, and on which page: the page's menu shows its + /// entries, or the page's own when it went to nothing that has a menu. + pub(crate) 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, @@ -1040,6 +1027,7 @@ impl DocView { signature_drag: Default::default(), cursor: Default::default(), router: Default::default(), + menu_for: None, press_cancelled: false, } } @@ -1675,6 +1663,7 @@ fn draw_pages( // comment, and Edit text's picture, too (below), as one on a page's empty part does. A // selected Prepare field keeps its menu, off the pages as on them. view.comments.context_at = None; + view.menu_for = None; } let mut frame = PageFrame { env: *env, @@ -1788,25 +1777,22 @@ 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::{CropPart, Owner, PicturePart}; + let menu_for = view.menu_for.map(|(_, owner)| owner); 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(part), .. }) if part != CropPart::Page) { 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 + && matches!(menu_for, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })) + && 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; @@ -1853,7 +1839,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(); @@ -1874,28 +1862,21 @@ 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 + && matches!(menu_for, Some(Owner::Picture { .. })) + && let Some(sel) = view.image_selection.clone() + { 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; } - if view.comments.selected.is_some() { + if let Some((page, Owner::Comment { index, .. })) = view.menu_for + && view.comments.selected == Some((page, index)) + { canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify); return; } @@ -1944,9 +1925,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)); - } *out = PageOut { canvas_action, clicked_link, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 97aad93100..4750004e35 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -134,7 +134,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Some(Owner::Picture { index, part }) if part != crate::page_router::PicturePart::Crop(crate::page_router::CropPart::Page) => Some(index), _ => None, }; - crate::edit_text_ui::picture_context(ui.ctx(), i, picture, view); + 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 @@ -347,6 +347,13 @@ 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. While a press is held only + // a link takes its menu, as only links take theirs then. + view.menu_for = match context { + Some(owner @ Owner::Link { .. }) => Some((hits.page, owner)), + _ if press_held || hits.tool == QuickTool::AddText && hits.can_modify => None, + owner => owner.map(|o| (hits.page, o)), + }; if press_held { return context; } diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index ed24d45839..e7548a2b89 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -21,10 +21,10 @@ 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, - picture_context, picture_keys, 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}; 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 de36b80f5c..ce99f48bac 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -264,20 +264,6 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool { a.into_iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3) } -/// Whether `p` is on the crop box or one of its grips (#5835). The Done, Cancel and Remove -/// Crop pills are widgets of their own, so they take their clicks before the canvas does. -pub(crate) fn crop_controls_contain( - image: &pdfcraft_engine::PageImage, - mode: &CropMode, - xf: &PageXform, - info: &DocInfo, - page: usize, - p: Pos2, -) -> bool { - crop_frames(xf, info, page, image, mode.keep) - .is_some_and(|(_, k)| k.contains(p) || crop_handles(k).iter().any(|h| h.distance(p) < CROP_GRIP_REACH)) -} - /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` /// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]). #[allow(clippy::cognitive_complexity)] @@ -678,11 +664,6 @@ 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: 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. @@ -991,20 +972,15 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage } /// A right-click on `page` that went to its picture `index` (on it, a grip of it, or the box kept -/// while it is cropped) selects it, keeping it as it is if it was selected, and opens the picture's -/// menu; one that went to anything else (`None`) unselects the picture selected and closes the menu. -pub(crate) fn picture_context(ctx: &egui::Context, page: usize, index: Option, view: &mut DocView) { +/// while it is cropped) selects it for its menu, keeping it as it is if it was selected; 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)); - } - ctx.data_mut(|d| d.insert_temp(image_menu_id(), true)); - } - None => { - view.image_selection = None; - ctx.data_mut(|d| d.insert_temp(image_menu_id(), false)); + 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, } } @@ -1029,7 +1005,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/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 003cf2d0fa..47f6ce9c49 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1679,6 +1679,27 @@ 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 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_all_by_label_contains("Copy").next().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 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] From a9f592fd64199c9092335540c94f40f9aec3d3cc Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 07:55:44 +0100 Subject: [PATCH 05/24] fix(ui): Edit text's drags end when its input is blocked Edit text ignores input while a dialog, the palette, another window or another tab has it. Only a drag of the objects selected together ended then; a paragraph's move or rewrap, a picture's move, resize, turn or crop and added content's move waited, and once the input came back with the button up they landed wherever the pointer was. While Edit text's input is blocked, `DocView::cancel_edit_text_gestures` abandons them all, as the objects' already was. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 10 ++++++++-- crates/ui-egui/src/canvas.rs | 12 ++++++++++++ crates/ui-egui/tests/it/editing.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 43a1e5ce1d..af588e6aaf 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -591,9 +591,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 ignores input while it is blocked: its drags end rather than wait, to + // land wherever the pointer is once it opens again. + if self.quick_tool == QuickTool::EditText { + 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 6855850615..7e77d473be 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -890,6 +890,18 @@ impl DocView { } } + /// 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 { + image.cancel_pointer_gesture(); + } + } + /// A view of a newly opened document, shown as `defaults` say. Opening a file then applies /// any layout or zoom the PDF itself asks for. pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index f8c56ae6ab..59de9d1374 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7419,6 +7419,36 @@ fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() { 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() { + let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); + 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] From 85fbcc436bbf3d5a329b9094c9815d237ff4836d Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:06:30 +0100 Subject: [PATCH 06/24] refactor(ui): the text layer, image pick and link following are told the press The text layer asked the router whether the press's owner kept its drag, link following asked it which link held the press, and the Link tool started a new link from a press any link held, since the router kept a declined drag's capture. - `PageRouter::drag_started`: a drag the owner declines (text markup; with the Link tool, a link) is nobody's once it starts, on the page it began on. The Link tool draws a new link only from a drag nobody holds. - `PageInput` carries the press (`PagePress`) and whether its drag is the page's text's (`text_drag`): the text layer and link following read them, and `PageRouter::owner` goes. Only the page view reads the router now. - Image pick acts on a press nobody holds: a link's click follows the link only, and picks no picture under it (the user's ruling). - The I-beam drops over a link only where a press would go to it, the top hit under the pointer (the user's ruling): the tools that select a link's text show it there. - `PageText::glyph_at` is the one test for the text under a point, for the text layer and Redact's boxes. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 5 ++ crates/render/src/text/tests.rs | 15 ++++++ crates/ui-egui/src/canvas.rs | 9 +++- crates/ui-egui/src/canvas/page_input.rs | 49 +++++++++++++------ .../ui-egui/src/canvas/page_pointer/tests.rs | 24 ++++----- crates/ui-egui/src/canvas/page_router.rs | 43 +++++++++++----- crates/ui-egui/src/tools/link_ui.rs | 11 +++-- crates/ui-egui/tests/it/pointer.rs | 45 +++++++++++++++++ 8 files changed, 156 insertions(+), 45 deletions(-) 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/canvas.rs b/crates/ui-egui/src/canvas.rs index 7e77d473be..14f9a92545 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1546,7 +1546,14 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i if let Some(p) = frame.right_click_on(xf.rect) { 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) }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 4750004e35..36f6f9d3f9 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, @@ -108,6 +114,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut view.router.press(i, at, tool); } } + // A drag that the press's owner declines is nobody's once it starts. + if resp.drag_started_by(egui::PointerButton::Primary) { + view.router.drag_started(); + } // 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 @@ -157,7 +167,7 @@ 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) }; @@ -216,7 +226,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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, @@ -529,7 +539,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() { @@ -538,7 +548,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); } } } @@ -583,11 +597,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> { @@ -602,16 +619,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); @@ -631,7 +648,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); @@ -646,7 +663,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.resp.drag_started() && press_here && gesture.text_drag { view.actions_dismissed = None; view.selection_drag = true; let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); @@ -718,7 +735,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), @@ -758,7 +775,9 @@ 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 { + // A press nobody holds picks the picture under it: a link's click follows the link only (ruling + // 5). + if tool == QuickTool::Select && input.press == PagePress::Free && !input.consumed { crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); } crate::image_pick::drop_stale(doc, view); @@ -815,7 +834,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/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index de4e0fb9ab..a3a3d8e882 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -483,20 +483,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_started(", "router.settle(", "router.cancel(", "PageRouter::begin(", "PageRouter::press(", + "PageRouter::drag_started(", "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"), ]; @@ -566,18 +568,16 @@ 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 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); 4] = [ - ("canvas/page_input.rs", "page_tool_input", "the page view: what the press is to each page (`press_for`)"), - ("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)"), -]; +/// page and tells each module what the press is to its page (`PagePress`); 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`)", +)]; /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 1302901136..5f4a08a56d 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -210,17 +210,21 @@ impl PageRouter { } } - /// Who holds the press on `page`. - pub(crate) fn owner(&self, page: usize) -> Option { - self.press_for(page).owner() - } - /// 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 { 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(crate) fn drag_started(&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) { if !any_down && !edges { @@ -260,16 +264,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()); } @@ -311,8 +315,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_started(); + 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_started(); + assert_eq!(r.press_for(1), PagePress::Mine(hit(Layer::Comment, 2).owner)); } } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 63db81b9f3..e30614900d 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 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, + ]) +} + /// 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); @@ -344,6 +360,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. From b3509dc6299d0739aa4bd47a0cbee6e776811b27 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:13:32 +0100 Subject: [PATCH 07/24] fix(ui): a comment's move, resize or reshape ends once its button is up A comment's gesture ended only on egui's `drag_stopped()`. When the pages missed its release (the button let go where they didn't see it), the gesture stayed live: it kept the page's frames from Fill & Sign and the shape tools, and the next drag that stopped on the page committed it, taking the comment there. It now ends, as the other page gestures do, on `drag_stopped()` or once the primary button is up, where the pointer is. Two tests planted a live move with the button up to check that a live move keeps its page's presses (`main`'s `active_comment_gesture_keeps_highlighted_link_click` and 4a's `a_fill_and_sign_click_places_nothing_while_a_mark_is_moved`). That state now ends at once, so both hold the press through the planted move instead: the intent stands, and each still fails with the live-move guard taken out. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/comments/input.rs | 4 ++- crates/ui-egui/tests/it/document_links.rs | 10 ++++++- crates/ui-egui/tests/it/pointer.rs | 33 ++++++++++++++++++++-- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ac3c2a6878..11f6e0549b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -229,7 +229,9 @@ fn select_input( if matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) { consumed = true; } - if resp.drag_stopped() + // A move, resize or reshape ends where its drag stops, or once the button is up when the pages + // missed its release. + if (resp.drag_stopped() || !cx.ptr.primary_down) && let Some(p) = pointer { match cv.gesture.clone() { 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/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index b163f5aee8..93ad9fbf80 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1569,6 +1569,27 @@ 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: a drag elsewhere +/// later moves nothing. 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 }); + h.hover_at(bottom); + h.run_steps(2); + assert!(h.state().views[0].comments.gesture.is_none(), "the move is over"); + 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] @@ -1763,8 +1784,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); @@ -1776,8 +1798,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"); } From 0378eee68798bc89922326912b09fdc6868f7589 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:18:26 +0100 Subject: [PATCH 08/24] test(ui): page modules never read the router; the compiler holds it With the Edit text group, the text layer and link following told what the press is to their page, only the page view reads the router. - The router's methods and `DocView.router` are `pub(in crate::canvas)`: outside `canvas` the compiler refuses a read or a write, aliases included, which the guard's text match could miss. `ROUTER_READERS` keeps only the page view's dispatcher (`page_tool_input`). - Rule (f): no function in a page module is named with "grabs" or "owns", which decide for themselves whether a press is theirs. - CLAUDE.md's "Page presses" rule says so: modules never read the router; they are told. Each rule seen failing against a violation planted in `#[cfg(any())]` code (a `router.keeps_drag()` read in `content_ui`, a `fn fill_grabs` in `fill_sign`), and a real router read from `content_ui` no longer compiles. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas.rs | 5 +-- .../ui-egui/src/canvas/page_pointer/tests.rs | 32 ++++++++++++++++--- crates/ui-egui/src/canvas/page_router.rs | 14 ++++---- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c7034b7a4..4dbeaae1ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone. - **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `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(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it. -- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Modules never read the router; they are told. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `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. - **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code: - No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 14f9a92545..6433e7e092 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -650,8 +650,9 @@ 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: the page's menu shows its /// entries, or the page's own when it went to nothing that has a menu. pub(crate) menu_for: Option<(usize, crate::page_router::Owner)>, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index a3a3d8e882..69b86dcdc0 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -525,9 +525,10 @@ 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 +/// 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) or keeps a behaviour no hit stands for (added +/// text's click with the tools that don't pick it up, crop mode's controls). `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] = [( @@ -571,14 +572,24 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ 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`); an unused entry fails -/// the test. +/// 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 its name has one of [`PRESS_CLAIMING_WORDS`] in it. +fn press_claiming_fn(line: &str) -> Vec { + declares(line.trim_start()).filter(|name| PRESS_CLAIMING_WORDS.iter().any(|w| name.contains(w))).map(str::to_owned).into_iter().collect() +} + /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -668,6 +679,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, @@ -726,6 +744,10 @@ 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!(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), diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 5f4a08a56d..b1ec7ff10a 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -182,7 +182,7 @@ 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); } @@ -192,7 +192,7 @@ impl PageRouter { /// 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; } @@ -201,7 +201,7 @@ impl PageRouter { } /// 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), @@ -212,21 +212,21 @@ impl PageRouter { /// 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(crate) fn drag_started(&mut self) { + pub(in crate::canvas) fn drag_started(&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; @@ -235,7 +235,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; } From 3c0ac1f77c351cc6219771d7de4bc71cfe17e019 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:20:01 +0100 Subject: [PATCH 09/24] test(ui): wait_for_cursor waits with step_until It hovered fifty times with a 10 ms sleep between, so a busy runner could run out of tries before the page's text or paragraph boxes arrived from their worker threads. It now steps frames with `support::step_until`, as the other waits for background work do, until the cursor is the one wanted or 30 seconds pass. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/pointer.rs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 93ad9fbf80..bd25d7e35d 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -245,16 +245,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 } @@ -408,7 +405,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); From 58fa110d2ddcf0a07c857a59130f04f89a89d91b Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:32:21 +0100 Subject: [PATCH 10/24] fix(ui): a right-button drag on the pages starts nothing a left one does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pages' presses are the primary button's (`PagePointer.edges`, the router's `began`), so a right-button drag was a press nobody held, and every tool that started on egui's `drag_started()`, which counts every button, started on it: the Rectangle tool drew a rectangle, Prepare's selection rectangle selected the fields in it, the Link tool opened Link Properties for a new link, the camera moved its quadrilateral to the page, and the Select tool selected text. On `main` too. `PagePointer.drag_started` is a primary drag of a press the pages hold, started this frame. The page modules read it instead of egui's (comments' shapes, callout and text box, Prepare, the Link tool, the camera, the text layer, image pick, and the Edit text group, whose owned presses were primary already); zoom, crop, Sign area and Redact already asked for the primary button. The guard refuses a bare `.drag_started()` in a page module (`NOT_PAGE_DRAGS` lists the widgets of their own: Organize's thumbnails, the signature pad, the Scan tab), and the router's `drag_started` is `drag_began`, so the rule reads cleanly. Tests, one per family, each seen failing with the fix reverted: `a_right_button_drag_draws_no_rectangle`, `…_selects_no_field`, `…_draws_no_link`, `…_leaves_the_camera_quadrilateral`, `…_selects_no_text`; the guard rule against a planted `#[cfg(any())]` call. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/image_pick.rs | 8 +- crates/ui-egui/src/canvas/page_input.rs | 8 +- crates/ui-egui/src/canvas/page_pointer.rs | 8 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 +++++- crates/ui-egui/src/canvas/page_router.rs | 6 +- crates/ui-egui/src/tools/camera_ui.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 8 +- crates/ui-egui/src/tools/content_ui.rs | 2 +- crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 4 +- crates/ui-egui/src/tools/link_ui.rs | 4 +- crates/ui-egui/src/tools/prepare.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 73 +++++++++++++++++++ 13 files changed, 125 insertions(+), 28 deletions(-) diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index cc938170da..543f5f963d 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}; @@ -30,11 +30,11 @@ pub(crate) fn page_input( page: usize, info: &DocInfo, doc: &pdfcraft_engine::Document, - pointer: Option, + ptr: &crate::page_pointer::PagePointer, 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; } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 36f6f9d3f9..e4227baecd 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -115,8 +115,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } } // A drag that the press's owner declines is nobody's once it starts. - if resp.drag_started_by(egui::PointerButton::Primary) { - view.router.drag_started(); + 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); @@ -663,7 +663,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 && gesture.text_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); @@ -778,7 +778,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P // A press nobody holds picks the picture under it: a link's click follows the link only (ruling // 5). if tool == QuickTool::Select && input.press == PagePress::Free && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, view); } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index e847009dd0..f3a1b13647 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -49,6 +49,9 @@ pub(crate) struct PagePointer { /// 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 { @@ -79,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, modifiers }; + 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, modifiers } + 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`. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 69b86dcdc0..06e6a720e2 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -271,6 +271,18 @@ 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. +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 +474,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")); } @@ -486,12 +506,12 @@ fn page_modules_claim_the_cursor_and_never_set_it() { const ROUTER_WRITES: [&str; 10] = [ "router.begin(", "router.press(", - "router.drag_started(", + "router.drag_began(", "router.settle(", "router.cancel(", "PageRouter::begin(", "PageRouter::press(", - "PageRouter::drag_started(", + "PageRouter::drag_began(", "PageRouter::settle(", "PageRouter::cancel(", ]; diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index b1ec7ff10a..1f5a2e5d24 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -219,7 +219,7 @@ impl PageRouter { /// 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_started(&mut self) { + pub(in crate::canvas) fn drag_began(&mut self) { if self.capture.as_ref().is_some_and(|c| c.declines_drag) { self.capture = None; } @@ -327,11 +327,11 @@ mod tests { let mut r = PageRouter::default(); r.begin(); r.press(1, [Hit { declines_drag: true, ..hit(Layer::Comment, 0) }], QuickTool::Select); - r.drag_started(); + 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_started(); + 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 11f6e0549b..d1218cc08b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -194,7 +194,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 { @@ -356,7 +356,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 { @@ -479,7 +479,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 { @@ -560,7 +560,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 be6b1a2734..374e3004fd 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -485,7 +485,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, ccx: ContentCx<'_>, view: &mut } // 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 resp.drag_started() { + if ptr.drag_started { let origin = ptr.origin.unwrap_or(p); match owned.filter(|_| picks_up) { Some((_, Some((left, top)))) => { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index e7548a2b89..7889ae9110 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -770,7 +770,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)), _ => None, }; - if resp.drag_started() + if ptr.drag_started && active.is_none() && let Some(o) = ptr.origin && let Some((block, part)) = owner 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 ce99f48bac..af190f66fd 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -280,7 +280,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_e // A drag takes what its press was on ([`crop_hit`]). if gate - && resp.drag_started() + && ptr.drag_started && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { part: PicturePart::Crop(part), .. }) = press { @@ -768,7 +768,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage // 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. if gate - && resp.drag_started() + && ptr.drag_started && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { index, part }) = press && index == i diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index e30614900d..f2e880983d 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -115,10 +115,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, 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"); +} + +/// 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(&[ From c10f87f673b3b4d8f85cbd333387d6f1a1696f36 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 08:38:18 +0100 Subject: [PATCH 11/24] fix(ui): a comment's gesture whose release the pages missed applies nothing `fix(ui): a comment's move, resize or reshape ends` ended a comment's move, resize or reshape once the button was up, and applied it where the pointer then was: when the pages missed the release (the page scrolled out of range, a tab switch, a tool switched mid-drag), the comment jumped to wherever the pointer next showed up over its page, and while the pointer stayed off the pages the gesture waited for it. Only a drag that stops on the page applies its gesture now; one that ends with the button up and no drag stopped there ends with nothing applied, whether the pointer is on the page or not. The test hovers away from the move's start, where the jump shows. Found in review. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/comments/input.rs | 14 ++++++++++---- crates/ui-egui/tests/it/pointer.rs | 10 ++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d1218cc08b..df05b9a635 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -226,12 +226,18 @@ 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, or once the button is up when the pages - // missed its release. - if (resp.drag_stopped() || !cx.ptr.primary_down) + // 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 { match cv.gesture.clone() { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9b3fd665c7..72b9602828 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1639,9 +1639,9 @@ 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: a drag elsewhere -/// later moves nothing. The move waited for the next drag's end on the page, and took the comment -/// there. +/// 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); @@ -1652,9 +1652,11 @@ fn a_comment_move_whose_release_the_pages_missed_ends_once_the_button_is_up() { 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 }); - h.hover_at(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"); From f592fe332a1d61fe8a7d6f93118e0aa48c9f180d Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 09:34:06 +0100 Subject: [PATCH 12/24] fix(ui): a right-click goes to what its tool reaches, and the menu follows it Found in review of the right-clicks 4b routes: - With Edit text, a right-click on a picture under a link went to the link, which Edit text doesn't follow: the page menu opened and the picture was unselected. A right-click counts links only for the tools that reach them (Select; the Link tool). - Edit text's paragraphs have no menu, and give way to the picture under them (the user's ruling): text over a picture, an OCR'd scan's among it, no longer hides the picture's menu. - With a Prepare field tool, or while a table is set up, Prepare's hit leaves fields out, so a right-click on the selected field opened the page menu. A right-click reaches fields whatever the field tool. - In crop mode, a right-click off the crop went to crop mode's whole-page hit and unselected every picture. Only one on the picture being cropped, its box or its grips is the crop's (#5900's area), which opens no menu; elsewhere it goes to what lies there, as without crop mode: another picture is selected for its menu. - Under a held press, the menu was the page's on anything but a link. It is the routed owner's when that owner is the one selected (the held press leaves the selections alone), the page's otherwise. `DocView.menu_for` is `pub(in crate::canvas)`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 26 ++++++---- crates/ui-egui/src/canvas/page_input.rs | 48 +++++++++++++------ .../ui-egui/src/tools/edit_text_ui/image.rs | 6 +-- crates/ui-egui/tests/it/editing.rs | 42 ++++++++++++++++ crates/ui-egui/tests/it/pointer.rs | 29 +++++++++++ 5 files changed, 125 insertions(+), 26 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6433e7e092..9ff5152c43 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -653,9 +653,10 @@ pub struct DocView { /// 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: the page's menu shows its - /// entries, or the page's own when it went to nothing that has a menu. - pub(crate) menu_for: Option<(usize, crate::page_router::Owner)>, + /// 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, @@ -1798,19 +1799,28 @@ fn draw_pages( view.current = current; } // The menu of what the right-click went to (`menu_for`), or else the page's. - use crate::page_router::{CropPart, Owner, PicturePart}; + 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, + }; resp.context_menu(|ui| { // 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(part), .. }) if part != CropPart::Page) { + 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, on a field. if preparing - && matches!(menu_for, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })) + && on_selected_field && let Some((name, wi)) = view.prepare.selected.clone() { // Several fields: Align, Center, Distribute, Set Fields to Same Size. @@ -1883,8 +1893,8 @@ fn draw_pages( } let can_paste_image = env.can_paste_image; if tool == QuickTool::EditText - && matches!(menu_for, Some(Owner::Picture { .. })) - && let Some(sel) = view.image_selection.clone() + && 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, on the picture's page: the right-click that opened diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e4227baecd..5458dc1e44 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -141,7 +141,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // page unselects it. if right.is_some() && tool == QuickTool::EditText && can_modify && !press_held { let picture = match context { - Some(Owner::Picture { index, part }) if part != crate::page_router::PicturePart::Crop(crate::page_router::CropPart::Page) => Some(index), + Some(Owner::Picture { index, .. }) => Some(index), _ => None, }; crate::edit_text_ui::picture_context(i, picture, view); @@ -357,13 +357,9 @@ 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. While a press is held only - // a link takes its menu, as only links take theirs then. - view.menu_for = match context { - Some(owner @ Owner::Link { .. }) => Some((hits.page, owner)), - _ if press_held || hits.tool == QuickTool::AddText && hits.can_modify => None, - owner => owner.map(|o| (hits.page, o)), - }; + // 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; } @@ -432,6 +428,11 @@ struct PageHits<'a> { } impl PageHits<'_> { + /// 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 { @@ -439,8 +440,11 @@ impl PageHits<'_> { } /// 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. + /// 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. @@ -451,7 +455,11 @@ impl PageHits<'_> { { return Some(press.owner); } - top(self.hits(view, p, true, None), QuickTool::Select).map(|h| h.owner) + let reached = 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 { .. })); + top(reached, QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text @@ -459,7 +467,8 @@ impl PageHits<'_> { /// 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). For a press with Shift or ⌘ in Edit text, only the objects - /// (`objects`, the page's); in crop mode, only the picture being cropped. + /// (`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( @@ -492,10 +501,19 @@ impl PageHits<'_> { 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`). - if tool == QuickTool::EditText && can_modify && view.image_selection.as_ref().is_some_and(|s| s.page == page && s.crop.is_some()) { - return crate::edit_text_ui::crop_hit(xf, page, info, images, view, p).into_iter().collect(); + // 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. + let (field_tool, prepare_allowed) = if context { (None, true) } 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 { 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 af190f66fd..2115e88faf 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -971,9 +971,9 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage owner.is_some() } -/// A right-click on `page` that went to its picture `index` (on it, a grip of it, or the box kept -/// while it is cropped) selects it for its menu, keeping it as it is if it was selected; one that -/// went to anything else (`None`) unselects the picture selected. +/// 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)) => { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 59de9d1374..f3d9fef095 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7313,6 +7313,48 @@ fn escape_during_a_crop_drag_keeps_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"); +} + +/// 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"); +} + /// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the /// app's, not the pointer's. #[test] diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 72b9602828..1bf7931122 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1838,6 +1838,35 @@ fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() { 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 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] From caf364670fcb8deb5b849c7cc089ecce7290bb51 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 09:39:26 +0100 Subject: [PATCH 13/24] fix(ui): Delete on a selected added picture deletes it alone `refactor(ui): crop mode is a hit of its picture` moved the picture's Delete to the app's key handling, which runs before the page view, so its edit applied before added content read the keys. Added content's selection mirrors the picture's when the picture is added content (`sync_image_selection`), and once the picture was gone that index named the next added item on the page: added content's own Delete, seeing the same key press, deleted it too. The picture's Delete consumes the key, and drops the mirrored selection with the picture's. Found in review. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/tools/edit_text_ui/image.rs | 9 ++++++ crates/ui-egui/tests/it/editing.rs | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+) 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 2115e88faf..50ec5ec78b 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -493,7 +493,16 @@ pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, doc: &pdfcra s.crop = None; } } else if !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, after this + // edit has applied. + 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 }); } } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index f3d9fef095..af0d02b9c6 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7369,6 +7369,38 @@ fn delete_removes_the_selected_picture_with_the_pointer_on_a_paragraph() { 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 is the dialog's: the selected picture stays, and so does crop mode /// under Escape and Enter. #[test] From d10a89fdd1e2b042bd704c4cdf3680788b6a072d Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 19:59:26 +0100 Subject: [PATCH 14/24] fix(ui): Edit text's presses that 4b's routing dropped reach their owners Found in review, each against `main`'s behaviour: - A double-click on one of the objects selected together only left the group: the second press was the group's. It is the object's under it, so added text opens for typing and a picture is selected, as before; a paragraph opens too (the user's ruling). - On a page whose objects can't be selected together, a click on the empty page left the picture selected: no area selection starts there to drop it, and the picture's own test that did is gone. A click nobody holds on the page unselects it. - Crop mode's grips stopped at the page's edge, so those of a picture filling its page could be taken from their inner half only. They reach past it, cursor, press and right-click: no page took a right-click there, which left crop mode and opened the page menu (#5900 keeps crop mode there). The box and the picture stop at the page's edge, as a clipped artwork's whole frame can reach over the next pages. - A press past a page's edge was the page's whenever it had any hit there: the objects selected together, or the open paragraph's frame, took presses on the next page and did nothing with them. Past the edge only the selected picture's knob and grips (crop mode's too) take a press, and the objects' hit stays on its page. - A drag from a hole in the selected picture (a clip with a hole) fell through, started an area selection and unselected it. The selected picture's frame is its own, as before. - Image pick: a drag or double-click from a link let the picked picture go before ruling 5, and does again; the link's click still picks none. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 11 +- crates/ui-egui/src/canvas/image_pick.rs | 9 +- crates/ui-egui/src/canvas/page_input.rs | 33 +++-- .../ui-egui/src/tools/edit_text_ui/image.rs | 71 ++++++----- crates/ui-egui/src/tools/object_ui.rs | 4 + crates/ui-egui/tests/it/editing.rs | 118 ++++++++++++++++-- crates/ui-egui/tests/it/object_selection.rs | 13 ++ crates/ui-egui/tests/it/pointer.rs | 81 ++++++++++++ 8 files changed, 285 insertions(+), 55 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9ff5152c43..140c38b53e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1242,11 +1242,10 @@ struct PageFrame<'a> { } impl PageFrame<'_> { - /// Where this frame's right-click is, on the page at `rect`, for the first page it is on: pages - /// that touch (the gaps hidden) can share a row of pixels, and a right-click there is one - /// page's, as a press is. - fn right_click_on(&mut self, rect: Rect) -> Option { - let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && rect.contains(*p))?; + /// Where this frame's right-click is, for the first page it is `on`: pages that touch (the gaps + /// hidden) can share a row of pixels, and a right-click there is one page's, as a press is. + fn right_click_on(&mut self, on: impl FnOnce(Pos2) -> bool) -> Option { + let p = self.pointer.filter(|p| !self.right_clicked && self.resp.secondary_clicked() && on(*p))?; self.right_clicked = true; Some(p) } @@ -1545,7 +1544,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = frame.right_click_on(xf.rect) { + if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { comments::anchor(&pcx, p, view); } PageInput { diff --git a/crates/ui-egui/src/canvas/image_pick.rs b/crates/ui-egui/src/canvas/image_pick.rs index 543f5f963d..e96a2b8430 100644 --- a/crates/ui-egui/src/canvas/image_pick.rs +++ b/crates/ui-egui/src/canvas/image_pick.rs @@ -22,8 +22,10 @@ pub struct PickedImage { const BLUE: Color32 = Color32::from_rgb(0x3A, 0x7B, 0xF0); /// A click on page `page` (Select tool, not taken by anything else) selects the picture under -/// it, even with text drawn over it (scans with an OCR layer, captions); dragging or a double -/// click still selects text, and clears the picture. +/// it, even with text drawn over it (scans with an OCR layer, captions), with `pick` (a press +/// nobody holds: a link's click follows the link only); dragging or a double click still selects +/// text, and clears the picture. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( resp: &egui::Response, xf: &PageXform, @@ -31,6 +33,7 @@ pub(crate) fn page_input( info: &DocInfo, doc: &pdfcraft_engine::Document, ptr: &crate::page_pointer::PagePointer, + pick: bool, view: &mut DocView, ) { let Some(p) = ptr.pos.filter(|p| xf.rect.contains(*p)) else { return }; @@ -38,7 +41,7 @@ pub(crate) fn page_input( view.picked_image = None; return; } - if !resp.clicked() { + if !resp.clicked() || !pick { return; } let Some(info_page) = info.pages.get(page) else { return }; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5458dc1e44..958c26eca0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -108,9 +108,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && !egui::Popup::is_any_open(ui.ctx()); let objects = adds.then(|| view.objects.inventory(doc, i)); let at = hits.at(view, o, objects.as_ref()); - // A press is the page's it lies on, or the one with something under it past its edge (the - // selected picture's rotate knob and grips). - if xf.rect.contains(o) || !at.is_empty() { + // A press is the page's it lies on, or past its edge the one whose selected picture has its + // rotate knob or a grip there (crop mode's grips too). + let past_edge = |h: &crate::page_router::Hit| { + use crate::page_router::PicturePart; + matches!(h.owner, Owner::Picture { part: PicturePart::Knob | PicturePart::Corner(_) | PicturePart::Crop(_), .. }) + }; + if xf.rect.contains(o) || at.iter().any(past_edge) { view.router.press(i, at, tool); } } @@ -126,6 +130,16 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let wants_under = editing || resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); let hovered = top(under.iter().cloned(), tool); + // A double-click on the objects selected together is the object's under it: the double-click + // leaves the group (`object_ui`), and added text opens for typing, a picture or a paragraph is + // the one taken (the user's ruling). + let press = match press { + PagePress::Mine(Owner::Objects { part: crate::page_router::ObjectsPart::Selection }) if resp.double_clicked() => { + top(under.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool) + .map_or(PagePress::Free, |h| PagePress::Mine(h.owner)) + } + press => press, + }; let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -135,7 +149,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // released, the right-click's own frame included, so the button is what tells. A touch's long // press, which egui reports as a right-click with the finger still down, opens its menu. let press_held = ptr.primary_down && !resp.long_touched(); - let right = frame.right_click_on(xf.rect); + // A right-click is the page's it lies on, or past its edge the one whose crop grip lies there, + // as a press is (#5835): what a right-click there would go to is asked only off the page. + let right = frame.right_click_on(|p| { + use crate::page_router::PicturePart; + xf.rect.contains(p) || matches!(hits.context_at(view, p, &[]), Some(Owner::Picture { part: PicturePart::Crop(_), .. })) + }); let context = right.and_then(|p| right_click(&hits, &under, view, p, press_held)); // Edit text: a right-click on a picture selects it for its menu, and one on anything else on the // page unselects it. @@ -794,9 +813,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A press nobody holds picks the picture under it: a link's click follows the link only (ruling - // 5). - if tool == QuickTool::Select && input.press == PagePress::Free && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, view); + // 5), though its drag or double-click still lets the picked picture go. + if tool == QuickTool::Select && matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, ptr, input.press == PagePress::Free, view); } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 50ec5ec78b..829a6d284b 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -571,40 +571,41 @@ pub(crate) fn handle_hit( /// The picture being cropped on `page`, under `p`: crop mode holds every press on its page, the /// controls beside its frame included, so the paragraphs under it and the empty page's marquee /// never take a crop button's press before the picture does. A grip of the box kept (within -/// [`CROP_GRIP_REACH`]), the box itself, the picture outside it (its frame shown or its whole frame), -/// or the rest of the page; and the controls past the page's edge, where they show the hand. `None` -/// when no picture on `page` is being cropped, or `p` is off the page and its controls. +/// [`CROP_GRIP_REACH`], past the page's edge too), the box itself, the picture outside it (its frame +/// shown or its whole frame), or the rest of the page; and the controls past the page's edge, where +/// they show the hand. `None` when no picture on `page` is being cropped, or `p` is off the page, +/// its grips and its controls: the box and the picture stop at the page's edge, as a clipped +/// artwork's whole frame can reach over the next pages. pub(crate) fn crop_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { let s = view.image_selection.as_ref().filter(|s| s.page == page)?; let mode = s.crop.as_ref()?; - let control = mode.controls.iter().any(|r| r.contains(p)); - if !control && !xf.rect.contains(p) { - return None; - } let crop = |part, icon| Some(Hit::new(Owner::Picture { index: s.index, part: PicturePart::Crop(part) }, Layer::Editor, icon)); - if control { + if mode.controls.iter().any(|r| r.contains(p)) { return crop(CropPart::Page, egui::CursorIcon::PointingHand); } - let Some(image) = images.get(s.index) else { return crop(CropPart::Page, egui::CursorIcon::Default) }; - if let Some((_, k)) = crop_frames(xf, info, page, image, mode.keep) { - if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH) { - return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); - } - if k.contains(p) { - return crop(CropPart::Keep, egui::CursorIcon::Grab); - } + let image = images.get(s.index); + let kept = image.and_then(|image| crop_frames(xf, info, page, image, mode.keep)).map(|(_, k)| k); + // The grips reach past the page's edge, where those of a picture filling its page lie half. + if let Some(g) = kept.and_then(|k| crop_handles(k).iter().position(|h| h.distance(p) < CROP_GRIP_REACH)) { + return crop(CropPart::Grip(g), GRIPS.get(g).map_or(egui::CursorIcon::Default, |grip| grip.cursor_icon())); } - let shown = ImageObb::from_image(image, xf, info, page); - let whole = ImageObb::from_matrix(image.matrix, xf, info, page); - if quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) { - return crop(CropPart::Cut, egui::CursorIcon::Default); + if !xf.rect.contains(p) { + return None; } - crop(CropPart::Page, egui::CursorIcon::Default) + if kept.is_some_and(|k| k.contains(p)) { + return crop(CropPart::Keep, egui::CursorIcon::Grab); + } + let on_picture = image.is_some_and(|image| { + let shown = ImageObb::from_image(image, xf, info, page); + let whole = ImageObb::from_matrix(image.matrix, xf, info, page); + quad_contains(shown.corners(), p) || quad_contains(whole.corners(), p) + }); + crop(if on_picture { CropPart::Cut } else { CropPart::Page }, egui::CursorIcon::Default) } -/// The picture under `p` (its shape, not only its box): a click selects it, a drag moves the -/// selected one. On the text layer, asked before the paragraphs: editable text on top of a -/// picture takes the press. +/// The picture under `p` (its shape, not only its box; for the selected one, its frame, a hole in it +/// included): a click selects it, a drag moves the selected one. On the text layer, asked before +/// the paragraphs: editable text on top of a picture takes the press. pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], view: &DocView, p: Pos2) -> Option { if !xf.rect.contains(p) { return None; @@ -613,7 +614,12 @@ pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pd let user = info.pages.get(page)?.view_to_user(x, y).map(f64::from); let index = images .iter() - .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user))?; + .rposition(|im| im.visible_rect.is_some_and(|r| xf.user_rect(info, page, r.map(|v| v as f32)).contains(p)) && im.contains(user)) + .or_else(|| { + let s = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none())?; + let image = images.get(s.index)?; + quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p).then_some(s.index) + })?; let selected = view.image_selection.as_ref().is_some_and(|s| s.page == page && s.index == index); let icon = if selected { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; Some(Hit::new(Owner::Picture { index, part: PicturePart::Body }, Layer::Text, icon)) @@ -966,16 +972,19 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage return true; } } - // A click selects the picture its press was on; its cursor is the hit's. + // A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this + // page leaves it unselected (where the page's objects can be selected together, the area + // selection the press starts has done so already). let owner = match press { PagePress::Mine(Owner::Picture { index, .. }) => Some(index), _ => None, }; - if gate - && resp.clicked() - && let Some(index) = owner - { - view.image_selection = Some(ImageSelection::at(page, index)); + if gate && resp.clicked() { + match owner { + Some(index) => view.image_selection = Some(ImageSelection::at(page, index)), + None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None, + None => {} + } } owner.is_some() } diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index e504aaecf1..2ae03c4083 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -187,6 +187,10 @@ fn leave_single(view: &mut DocView) { /// takes hold of the box as in Acrobat: a click selects it, a drag moves it, its text keeps the /// caret. pub(crate) fn hit(view: &DocView, xf: &PageXform, info: &DocInfo, page: usize, open: Option, p: Pos2) -> Option { + // Past the page's edge is no object's: the next page lies there. + if !xf.rect.contains(p) { + return None; + } let s = &view.objects; if s.page == Some(page) && !s.selected.is_empty() && s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o))).contains(p) { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index af0d02b9c6..d505ca2873 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -6503,6 +6503,15 @@ fn clipped_artwork_with_labels() -> Vec { /// `own_clip` draws the image alone in its clip (`q re W n cm Do Q`), which moves with it (#1049). fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { + artwork_drawn(if own_clip { + &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + } else { + &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] + }) +} + +/// [`fixture`]'s two pages, each drawn by `content`, with a one-pixel picture as `/Im0`. +fn artwork_drawn(content: &[u8]) -> Vec { use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental}; let mut doc = Document::open(std::sync::Arc::new(fixture(2))).unwrap(); let mut image = Dict::new(); @@ -6514,12 +6523,7 @@ fn clipped_artwork_with_labels_drawn(own_clip: bool) -> Vec { image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB")); let image = doc.add(Object::Stream(Stream::flate(image, &[220, 235, 245]))); for page in pdfcraft_model::pages(&doc) { - let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), - if own_clip { - &b"q 30 170 130 80 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - } else { - &b"q 30 170 130 80 re W* n q 800 0 0 900 -300 -400 cm /Im0 Do Q Q BT /F1 12 Tf 30 130 Td (Editable caption) Tj ET BT /F1 12 Tf 40 220 Td (Overlay) Tj ET BT /F1 9 Tf 154 249 Td (Corner label) Tj ET"[..] - }))); + let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); let mut resources = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); let mut xo = Dict::new(); xo.set(b"Im0".to_vec(), Object::Ref(image)); @@ -7233,10 +7237,15 @@ fn cropping_a_picture_with_its_handles() { /// Both pages of [`clipped_artwork_with_labels`] at 100% in Edit text, the picture on page 0 /// selected (and being cropped, with `crop`). fn picture_pages(crop: bool) -> Harness<'static, PdfCraftApp> { - let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(|_cc| { + picture_pages_of(clipped_artwork_with_labels(), crop) +} + +/// [`picture_pages`], of `pdf`. +fn picture_pages_of(pdf: Vec, crop: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1100.0)).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("clipped.pdf", None, clipped_artwork_with_labels()).unwrap(); + app.open_bytes("clipped.pdf", None, pdf).unwrap(); app.set_option("left", "closed").unwrap(); app.set_option("zoom", "100").unwrap(); app @@ -7355,6 +7364,99 @@ fn in_crop_mode_a_right_click_on_another_picture_selects_it() { assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); } +/// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the +/// picture: its frame is its own. The press fell through the hole, started an area selection and +/// unselected it. +#[test] +fn a_drag_from_a_hole_in_the_selected_picture_moves_it() { + // The picture shows at x 30..160, y 170..250, around a hole at x 70..120, y 190..230. + let mut h = picture_pages_of(artwork_drawn(b"q 30 170 130 80 re 70 190 50 40 re W* n 800 0 0 900 -300 -400 cm /Im0 Do Q"), false); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let hole = page_at(&h, 0, 95.0, 210.0); + h.hover_at(hole); + h.run_steps(1); + h.drag_at(hole); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(hole + egui::vec2(5.0 * k as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(hole + egui::vec2(20.0, 0.0)); + h.run_steps(4); + let after = h.state().session.get(id).unwrap().page_images(0)[0].rect; + assert!(after[0] - before[0] > 5.0, "the picture moved right: {before:?} → {after:?}"); +} + +/// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie +/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835). No page took it, +/// which left crop mode. +#[test] +fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let keep = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(keep)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + crop_secondary_click(&mut h, r.right_bottom() + egui::vec2(4.0, 4.0)); + assert!(h.state().views[0].image_selection.as_ref().is_some_and(|s| s.crop.is_some()), "crop mode stays"); + for label in ["Flip horizontal", "Rotate view clockwise"] { + assert!(h.query_all_by_label(label).next().is_none(), "no menu: {label}"); + } +} + +/// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie +/// half: a drag from just outside the page's corner trims the picture. Only the half on the page took +/// the press. +#[test] +fn a_crop_grip_past_the_page_edge_drags() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().page_images(0)[0].visible_rect.unwrap(); + let mut selection = pdfcraft_ui_egui::ImageSelection::at(0, 0); + selection.crop = Some(pdfcraft_ui_egui::CropMode::new(before)); + h.state_mut().views[0].image_selection = Some(selection); + h.run_steps(3); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let outside = r.right_bottom() + egui::vec2(4.0, 4.0); + let to = r.right_bottom() - egui::vec2(r.width() / 4.0, r.height() / 4.0); + h.hover_at(outside); + h.run_steps(1); + h.drag_at(outside); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(outside + (to - outside) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(2); + let keep = h.state().views[0].image_selection.as_ref().and_then(|s| s.crop.as_ref()).map(|c| c.keep); + assert!(keep.is_some_and(|k| k[2] < before[2] - 1.0 && k[1] > before[1] + 1.0), "trimmed from the corner: {keep:?} of {before:?}"); +} + /// Delete removes the selected picture wherever the pointer is, on a paragraph too: the key is the /// app's, not the pointer's. #[test] diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 54dcfa5f33..8a74fea261 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -438,3 +438,16 @@ fn a_press_just_outside_a_picture_starts_an_area_selection() { h.run_steps(4); h.get_by_label("2 objects selected"); } + +/// A double-click on one of the objects selected together leaves the group and reaches that one: +/// added text opens for typing. The double-click only left the group. +#[test] +fn a_double_click_on_a_grouped_object_reaches_it() { + let mut h = harness(); + marquee(&mut h); + // The added note is x 130..230, y 130..144 from the page's top. + let note = at(&h, 180.0, 137.0); + crate::support::input::double_click(&mut h, note); + h.run_steps(2); + assert!(h.state().views[0].content.draft.as_ref().is_some_and(|d| d.index == Some(0)), "the added note opens for typing"); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1bf7931122..3230ceb83f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -81,6 +81,31 @@ fn linked_picture_pdf() -> Vec { ]) } +/// Two 300×400 pages, the first with a line of text at its bottom edge, at y 4, the second with one +/// near its top, at y 380. +fn bottom_line_pages_pdf() -> Vec { + let bottom = stream("BT /F1 12 Tf 40 4 Td (Bottom line) Tj ET"); + let top = stream("BT /F1 12 Tf 40 380 Td (Top line) Tj ET"); + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + "<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 7 0 R >> >> >>", + bottom.as_str(), + top.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a picture at x 50..250, y 250..350, and graphics states nested deeper than +/// the objects selected together are read through: they can't be selected together on this page. +fn ungroupable_picture_pdf() -> Vec { + let picture = + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\nA\nendstream"; + let content = stream(&format!("q 200 0 0 100 50 250 cm /Im0 Do Q {}{}", "q ".repeat(1030), "Q ".repeat(1030))); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >>", content.as_str(), picture]) +} + /// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. fn comment_on_text_pdf() -> Vec { let text = stream(LINE); @@ -1867,6 +1892,62 @@ fn with_edit_text_a_right_click_on_a_picture_under_a_link_selects_the_picture() assert_eq!(selected, Some((0, 0)), "the picture is selected for its menu"); } +/// With the gaps between pages hidden, a drag from the top of page 2 under page 1's open paragraph is +/// page 2's: its area selection takes the line there. The paragraph's frame, which reaches past +/// page 1's edge, took the press and did nothing with it. +#[test] +fn a_drag_past_a_pages_edge_under_its_open_paragraph_is_the_next_pages() { + let mut h = open(bottom_line_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let (one, two) = { + let v = &h.state().views[0]; + (v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen")) + }; + let line = egui::pos2(one.min.x + 60.0, one.max.y - 8.0); + mouse_click(&mut h, line); + assert!(h.state().views[0].line_editor.is_some(), "the bottom line is open"); + let below = egui::pos2(one.min.x + 60.0, two.min.y + 3.0); + mouse_drag(&mut h, below, egui::vec2(200.0, 30.0)); + // What the area selection took, Delete removes. + h.key_press(egui::Key::Delete); + h.run_steps(4); + let s = h.state(); + assert!(s.session.get(s.views[0].id).unwrap().text_blocks(1).is_empty(), "page 2's area selection took its line"); +} + +/// With Edit text, a click on the empty page leaves the selected picture unselected, on a page whose +/// objects can't be selected together too, where no area selection starts to do it. It stayed. +#[test] +fn a_click_beside_the_picture_unselects_it_where_objects_cant_be_grouped() { + let mut h = open(ungroupable_picture_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 1, "the picture"); + assert!(doc.editable_objects(0).is_err(), "and no objects to group"); + h.state_mut().views[0].image_selection = Some(pdfcraft_ui_egui::ImageSelection::at(0, 0)); + h.run_steps(2); + let blank = at(&h, 150.0, 100.0); + mouse_click(&mut h, blank); + assert!(h.state().views[0].image_selection.is_none(), "the picture is unselected"); +} + +/// With the Select tool a drag that starts on a link lets the picked picture go, as a drag elsewhere +/// does, though the link's click picks none. +#[test] +fn a_drag_from_a_link_lets_the_picked_picture_go() { + let mut h = open(linked_picture_pdf(), "150", false); + let beside = at(&h, 70.0, 300.0); + mouse_click(&mut h, beside); + assert!(h.state().views[0].picked_image.is_some(), "the picture is picked"); + let on_link = at(&h, 150.0, 300.0); + mouse_drag(&mut h, on_link, egui::vec2(30.0, 0.0)); + assert!(h.state().views[0].picked_image.is_none(), "the drag lets it go"); +} + /// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is /// started under it, as before. #[test] From a5ed8c68bf1328d022e0dfd0b9b5344e60084ea2 Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 09:58:32 +0100 Subject: [PATCH 15/24] test(ui): the page guard sees router aliases and scoped names; true docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: - Rule (f) missed `pub(in …)` functions, the visibility the guard's commit brought in (`declares` strips any `pub(…)`), and flagged names that merely end in its words (`dropdowns`): it now matches whole words of the name. - Inside `canvas` the narrowed visibility doesn't hold, and a router taken whole (`let r = &view.router;`) got past the checks by method name; such a use now counts as the router's (`takes_the_router`). - Docs: the router's module doc and `PagePress::Free` say a declined drag is nobody's once it starts; Prepare's note says a highlight's drag is Prepare's; Add text's paragraph note names `ParagraphCx::adding`; a test's doc claimed crop-mode keys it doesn't press. - One source each for the objects' menu test (`object_ui::menu_open`) and Edit text's gesture cancel (`cancel_pointer_gestures` calls `cancel_edit_text_gestures`); the page-menu test asserts an entry only the page's menu has. Each guard addition seen failing against a violation planted in `#[cfg(any())]` code in `canvas/area.rs`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 7 +---- crates/ui-egui/src/canvas/page_input.rs | 9 +++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 29 ++++++++++++++++--- crates/ui-egui/src/canvas/page_router.rs | 11 ++++--- crates/ui-egui/src/tools/object_ui.rs | 8 ++++- crates/ui-egui/src/tools/prepare.rs | 3 +- crates/ui-egui/tests/it/editing.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 2 +- 8 files changed, 48 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 140c38b53e..f66a0eef0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -878,18 +878,13 @@ 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.content.cancel_pointer_gesture(); - self.objects.cancel_pointer_gesture(); - if let Some(image) = &mut self.image_selection { - image.cancel_pointer_gesture(); - } + self.cancel_edit_text_gestures(); } /// Abandon Edit text's pointer gestures (a paragraph moved or rewrapped, a picture moved, diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 958c26eca0..cffb7ffc9b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -104,8 +104,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && can_modify && xf.rect.contains(o) && crate::object_ui::adds(ptr.modifiers) - && !resp.context_menu_opened() - && !egui::Popup::is_any_open(ui.ctx()); + && !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 @@ -549,9 +548,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); ( diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 06e6a720e2..f0392b4d7a 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -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 { @@ -605,9 +609,21 @@ const ROUTER_READERS: [(&str, &str, &str); 1] = [( /// 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 its name has one of [`PRESS_CLAIMING_WORDS`] in it. +/// 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| PRESS_CLAIMING_WORDS.iter().any(|w| name.contains(w))).map(str::to_owned).into_iter().collect() + 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. @@ -665,7 +681,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( @@ -766,6 +782,11 @@ fn the_page_guards_read_hit_tests_split_calls_and_aliases() { 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"; diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 1f5a2e5d24..2479e9860b 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,8 +6,10 @@ //! 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; @@ -143,8 +145,9 @@ 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). diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index 2ae03c4083..b7551ab94a 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -160,6 +160,12 @@ fn screen(xf: &PageXform, info: &DocInfo, page: usize, object: &EditableObject) xf.user_rect(info, page, object.rect.map(|v| v as f32)) } +/// 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 @@ -247,7 +253,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, press: PagePress, view: &mut D 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; } match press { diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index e169bfea53..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 { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index d505ca2873..2d231ab359 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7503,8 +7503,7 @@ fn delete_on_a_selected_added_picture_deletes_it_alone() { assert_eq!(texts, ["Caption"], "and the text placed after it stays"); } -/// With a dialog open, Delete is the dialog's: the selected picture stays, and so does crop mode -/// under Escape and Enter. +/// 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); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 3230ceb83f..3bb43e56d6 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1856,7 +1856,7 @@ fn with_prepare_a_right_click_beside_the_selected_field_opens_the_pages_menu() { 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_all_by_label_contains("Copy").next().is_some(), "the page's menu"); + 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); From 79205fe4ac285e991f61305681956435e419de8f Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:03:07 +0100 Subject: [PATCH 16/24] fix(ui): with Edit text, what has no menu gives way to a picture under it Found in review. With Edit text, a right-click on added text or on an open paragraph's frame went to that, which has no menu: the picture under it was unselected and the page's menu opened. `main` opens the picture's. Those give way to a picture under them, as paragraphs do, and to nothing else: a comment under added text stays unselected, as before. A comment in `draw_pages` said a selected Prepare field keeps its menu off the pages. Since the page menu follows the right-click, it stays selected and the menu is the page's. Tests, each seen failing with the fix reverted: `a_right_click_on_added_text_over_a_picture_selects_the_picture`, `a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture`. `a_right_click_with_edit_text_on_added_text_over_a_comment_selects_no_comment` is a pin, seen failing with added text dropped from the hits outright. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/canvas/page_input.rs | 16 ++++++++-- crates/ui-egui/tests/it/editing.rs | 41 +++++++++++++++++++++++++ crates/ui-egui/tests/it/pointer.rs | 16 ++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f66a0eef0e..5ea7a2fc53 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1676,7 +1676,7 @@ fn draw_pages( // 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, and Edit text's picture, too (below), as one on a page's empty part does. A - // selected Prepare field keeps its menu, off the pages as on them. + // selected Prepare field stays selected, and the menu is the page's there too. view.comments.context_at = None; view.menu_for = None; } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index cffb7ffc9b..3cf1ae8ec2 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -462,6 +462,9 @@ impl PageHits<'_> { /// 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 @@ -473,11 +476,18 @@ impl PageHits<'_> { { return Some(press.owner); } - let reached = self + 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 { .. })); - top(reached, QuickTool::Select).map(|h| h.owner) + .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 diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 2d231ab359..0ec0c6f781 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7336,6 +7336,47 @@ fn a_right_click_on_text_over_a_picture_selects_the_picture() { 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. It unselected the picture and opened the page's. +#[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. It unselected the picture and opened the page's menu. +#[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"); +} + /// 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] diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 3bb43e56d6..71879d6e5e 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1579,6 +1579,22 @@ fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_comme 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 /// selected. #[test] From 1ee332ae5555c5f582994778814f7c14fa3f9923 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:36:15 +0100 Subject: [PATCH 17/24] fix(ui): a picture's keys act only while its page is drawn Found in review. Delete, Backspace and crop's Enter and Escape moved to the app's keys, which run before anything is drawn and don't look at what is on screen. So with Edit text and a picture selected: - Delete in the page grid removed the hidden picture and kept the pages; - Delete with the pointer on the Pages panel did the same; - with the picture's page scrolled away or in single-page view on another page, Delete removed a picture the reader couldn't see, and Enter applied its crop. `main` read these keys in the picture's own page pass, which none of those cases reach. `picture_keys` runs there again, from `page_tool_input`, under Edit text's gate (so no dialog gets them), and before the group acts. Delete still works wherever on the pages the pointer is. Tests, each seen failing with the fix reverted: `delete_in_the_page_grid_deletes_the_page_and_not_the_hidden_picture`, `a_selected_pictures_keys_wait_for_its_page_to_be_on_screen`, `delete_with_the_pointer_on_the_pages_panel_deletes_a_page`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/keys.rs | 2 - crates/ui-egui/src/canvas/page_input.rs | 3 + .../ui-egui/src/tools/edit_text_ui/image.rs | 29 ++++---- crates/ui-egui/tests/it/editing.rs | 66 +++++++++++++++++++ 4 files changed, 83 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/app/keys.rs b/crates/ui-egui/src/app/keys.rs index 2fc44da6f2..c014453806 100644 --- a/crates/ui-egui/src/app/keys.rs +++ b/crates/ui-egui/src/app/keys.rs @@ -66,8 +66,6 @@ impl PdfCraftApp { { object_ui::nudge_keys(ctx, &mut self.views[i], doc); object_ui::delete_keys(ctx, &mut self.views[i]); - let held = ctx.input(|input| input.pointer.any_down()); - edit_text_ui::picture_keys(ctx, &mut self.views[i], doc, held); } else if let Some(doc) = self.session.get(self.views[i].id).filter(|d| d.allows_annotation()) { comments::nudge_keys(ctx, &mut self.views[i], &doc.info); } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 3cf1ae8ec2..dbf0878bb3 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -193,6 +193,9 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // 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_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 { 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 829a6d284b..f39c644487 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -467,24 +467,24 @@ fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) { } } -/// The selected picture's keys, from the app's own (none reach it under a dialog): Delete or -/// Backspace removes it, wherever the pointer is, unless it is being dragged or cropped. In crop -/// mode Enter applies the crop and Escape leaves it, though not while a button is down (`held`): -/// Escape then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode -/// stays. Nothing while a text field has the keyboard. -pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, doc: &pdfcraft_engine::Document, held: bool) { +/// The keys of the picture selected on `page`, a page being drawn, so the keys never reach a picture +/// off the screen, and the page grid and the Pages panel have had theirs. Delete or Backspace removes +/// it, wherever on the pages the pointer is, unless it is being dragged or cropped. In crop mode +/// Enter applies the crop and Escape leaves it, though not while a button is down (`held`): Escape +/// then cancels the press the pages hold, if any (`canvas::cancel_on_escape`), and crop mode stays. +/// Nothing while a text field has the keyboard. +pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, images: &[pdfcraft_engine::PageImage], held: bool) { if ctx.egui_wants_keyboard_input() { return; } - let Some(s) = view.image_selection.as_ref() else { return }; - let (page, index) = (s.page, s.index); + let 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)); - // The page's pictures are read only when a key asks for one. - let image = || doc.page_images(page).into_iter().nth(index); + 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); + if let Some(image) = image { + apply_crop_mode(view, image); } } else if !held && key(egui::Key::Escape) @@ -492,9 +492,8 @@ pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, doc: &pdfcra { s.crop = None; } - } else if !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, after this - // edit has applied. + } else if !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); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 0ec0c6f781..06ff943bb5 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7377,6 +7377,72 @@ fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture( assert!(h.query_by_label("Flip horizontal").is_some(), "its menu opens"); } +/// 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"); + } +} + +/// 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] From fd091beaab6422ec23bc53dd6259e1b0e2d9a1f1 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:38:13 +0100 Subject: [PATCH 18/24] fix(ui): a drag from the selected picture moves it under another picture Found in review. The selected picture's frame is its own on `main`: a drag from it moves it, though another picture lies over it there. The routed press was the top picture's, and the drag only started for the selected one's, so nothing moved. `PicturePart::OverSelected` says so: a click there selects the picture on top, as before, and a drag moves the selected one. Test, seen failing with the part planted away: `a_drag_from_the_selected_picture_under_another_moves_the_selected_one`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_router.rs | 3 + .../ui-egui/src/tools/edit_text_ui/image.rs | 24 ++++---- crates/ui-egui/tests/it/editing.rs | 58 +++++++++++++++++++ 3 files changed, 74 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 2479e9860b..a8a07e6ad7 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -48,6 +48,9 @@ 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. 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 f39c644487..7a7ad701a2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -611,17 +611,19 @@ pub(crate) fn body_hit(xf: &PageXform, page: usize, info: &DocInfo, images: &[pd } 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)) - .or_else(|| { - let s = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_none())?; - let image = images.get(s.index)?; - quad_contains(ImageObb::from_image(image, xf, info, page).corners(), p).then_some(s.index) - })?; + let 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. @@ -785,7 +787,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage && ptr.drag_started && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { index, part }) = press - && index == i + && (index == i || part == PicturePart::OverSelected) && let Some(s) = view.image_selection.as_mut() { match part { @@ -803,7 +805,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage }); } } - PicturePart::Body => { + PicturePart::Body | PicturePart::OverSelected => { s.drag = Some(ImageDrag { start: o, target: DragTarget::Move, initial_rect: b, initial_matrix: image.matrix }); } PicturePart::Crop(_) => {} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 06ff943bb5..cd82d95aec 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7377,6 +7377,64 @@ fn a_right_click_on_an_open_paragraphs_frame_over_a_picture_selects_the_picture( 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"); +} + /// In the page grid, Delete deletes the page chosen there. The picture selected before the grid /// opened is not on screen, and stays. #[test] From dd9ec46efc0c53110fb886ffb4611394e1cf1da6 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:44:31 +0100 Subject: [PATCH 19/24] fix(ui): a picture's grip and knob take their drag from the press Found in review. On `main` the selected picture's grips and rotate knob were drag-only widgets of their own, so their drag began at the press. Their widgets went, and the page's response is click-and-drag, which calls it a drag only past egui's click distance: a move of a few pixels resized nothing. They take their drag from the press again. A click that doesn't move edits nothing, as before; a drag of the picture itself still waits, since a click selects. Tests: `a_short_drag_from_a_picture_grip_resizes_it` (seen failing with the fix reverted), `a_click_on_a_picture_grip_changes_nothing` (a pin, seen failing with the release planted to edit always). Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/tools/edit_text_ui/image.rs | 9 +++-- crates/ui-egui/tests/it/editing.rs | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) 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 7a7ad701a2..b63f876298 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -782,13 +782,16 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage } // 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 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 - && ptr.drag_started && let Some(o) = ptr.origin && let PagePress::Mine(Owner::Picture { index, part }) = press && (index == i || part == PicturePart::OverSelected) - && let Some(s) = view.image_selection.as_mut() + && (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 => { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index cd82d95aec..d40a52e373 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7435,6 +7435,39 @@ fn a_drag_from_the_selected_picture_under_another_moves_the_selected_one() { 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"); +} + /// In the page grid, Delete deletes the page chosen there. The picture selected before the grid /// opened is not on screen, and stays. #[test] From 92622135895fe4afd1c9f36aacc557867287503b Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:44:31 +0100 Subject: [PATCH 20/24] fix(ui): Add text's held draft and a locked document keep their limits Found in review, each against `main`: - With Add text holding text it can't draw, a drag from a paragraph moved the paragraph; only its click was refused. Neither acts now (#125's draft stays where it is being typed). - A right-click reached a Prepare field whatever the field tool and while a table is set up (a review ruling), but also on a document that can't be changed, where Prepare stays open after a tab switch: it selected the field and opened a menu of entries the engine would refuse. It selects nothing there. Tests, each seen failing with its fix reverted: `add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw`, `with_prepare_on_a_locked_document_a_right_click_on_a_field_selects_nothing`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 5 ++-- crates/ui-egui/src/tools/edit_text_ui.rs | 3 +++ crates/ui-egui/tests/it/editing.rs | 33 +++++++++++++++++++++++- crates/ui-egui/tests/it/pointer.rs | 19 ++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index dbf0878bb3..95060c3f88 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -543,8 +543,9 @@ impl PageHits<'_> { return crop.into_iter().collect(); } } - // A right-click reaches a Prepare field whatever the field tool, and while a table is set up. - let (field_tool, prepare_allowed) = if context { (None, true) } else { (field_tool, prepare_allowed) }; + // 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 { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 7889ae9110..f2833de356 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -770,8 +770,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text PagePress::Mine(Owner::Paragraph { block, part }) => Some((block, part)), _ => None, }; + // 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 { diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index d40a52e373..8edfa857d2 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, @@ -7468,6 +7468,37 @@ fn a_click_on_a_picture_grip_changes_nothing() { 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 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"); +} + /// In the page grid, Delete deletes the page chosen there. The picture selected before the grid /// opened is not on screen, and stays. #[test] diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 71879d6e5e..23b408aebd 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1858,6 +1858,25 @@ 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 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. From 8102109d198f338ccd81202d374f6d9e2e982256 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 15:58:47 +0100 Subject: [PATCH 21/24] fix(ui): a right-button drag leaves a text selection alone Found in review. `fix(ui): a right-button drag on the pages starts nothing a left one does` covered drag starts. A right-button drag still moved the end of a selection already made, as `update_dragged_selection` tested a drag by any button (`main` replaced the selection instead). And its end finished a selection whichever button it was, so with a markup tool, Replace Text or Redact a selection left from another tool became a comment. Both read the primary button now. Tests, each seen failing with its fix reverted (Redact's on its own): `a_right_button_drag_leaves_a_text_selection_alone`, `a_right_button_drag_makes_no_mark_of_a_text_selection`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 5 ++-- crates/ui-egui/src/tools/redact_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 35 ++++++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 95060c3f88..d6bd32cb72 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -734,7 +734,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 { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index df05b9a635..506da1483b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -309,7 +309,8 @@ fn select_input( /// 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(); @@ -319,7 +320,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() 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/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 23b408aebd..0012d30d61 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1526,6 +1526,41 @@ fn a_right_button_drag_leaves_the_camera_quadrilateral() { 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() { From a21f098049eae01a18b241301ee41f17b61d09d7 Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 19:31:18 +0100 Subject: [PATCH 22/24] test(ui): true docs and no dead code after the review of the routing Found in review. No change in behaviour. - The guard's `PAGE_HIT_CALLS` refused nothing once `main`'s press-handler check went; it only failed when it was stale, so it goes. - The guard's notes said `canvas/image_pick.rs` uses none of the marks (it takes `PagePointer` now), that page modules read only `PagePointer::drag_started` (some ask for the primary button themselves), and left out the point tests that tell a tool a press began on its page. - `content_ui`'s and the comments' notes said added text is its own with every tool, and that added content decides for itself. - `object_ui`'s `!double_clicked()` can't fail (the page view re-routes that press), and Fill & Sign's preview recomputed `hovered`. - `Capture` is private, and `top` is `canvas`'s. - Test notes gave as history bugs that only this branch ever had. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 5 +-- .../ui-egui/src/canvas/page_pointer/tests.rs | 41 ++++--------------- crates/ui-egui/src/canvas/page_router.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 4 +- crates/ui-egui/src/tools/content_ui.rs | 2 +- crates/ui-egui/src/tools/object_ui.rs | 2 +- crates/ui-egui/tests/it/editing.rs | 13 +++--- crates/ui-egui/tests/it/object_selection.rs | 2 +- 8 files changed, 22 insertions(+), 51 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index d6bd32cb72..0df0ebb5ea 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -168,7 +168,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // 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 { let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view); @@ -241,8 +241,7 @@ 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; diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f0392b4d7a..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. @@ -276,7 +276,8 @@ fn declares(line: &str) -> Option<&str> { } /// 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. +/// 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: @@ -550,11 +551,11 @@ 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`). Point tests by other names are not seen: what is -/// left of them paints (a picture's hover outline) or keeps a behaviour no hit stands for (added -/// text's click with the tools that don't pick it up, crop mode's controls). `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. +/// 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", @@ -633,24 +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 (HIT_TESTERS says who may): an entry no page module calls -/// any more fails the test. -const PAGE_HIT_CALLS: [&str; 13] = [ - "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(", - "object_ui::modified_hit(", - "content_ui::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 @@ -742,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)| { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index a8a07e6ad7..463f315e0c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -135,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), @@ -172,7 +172,7 @@ 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, } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 506da1483b..4a0bf87803 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); diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 374e3004fd..581b102a41 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -385,7 +385,7 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, added: &[Added], 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 every tool ([`hit`]). + /// 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, diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index b7551ab94a..b57f3ad79b 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -375,7 +375,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, vie return true; } // The selection moves. - PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) if !resp.double_clicked() => { + PagePress::Mine(Owner::Objects { part: ObjectsPart::Selection }) => { s.gesture = Some(Gesture { start: p, now: p, marquee: false, additive: false }); return true; } diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 8edfa857d2..54c5397840 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7337,7 +7337,7 @@ fn a_right_click_on_text_over_a_picture_selects_the_picture() { } /// 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. It unselected the picture and opened the page's. +/// 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); @@ -7355,7 +7355,7 @@ fn a_right_click_on_added_text_over_a_picture_selects_the_picture() { } /// With Edit text, a right-click on the frame of a paragraph open for typing over a picture is the -/// picture's too. It unselected the picture and opened the page's menu. +/// 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); @@ -7594,8 +7594,7 @@ fn in_crop_mode_a_right_click_on_another_picture_selects_it() { } /// A drag from a hole in the selected picture (drawn through a clip with a hole in it) moves the -/// picture: its frame is its own. The press fell through the hole, started an area selection and -/// unselected it. +/// 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. @@ -7618,8 +7617,7 @@ fn a_drag_from_a_hole_in_the_selected_picture_moves_it() { } /// A right-click on a crop grip past the page's edge, where those of a picture filling its page lie -/// half, is the crop's, as on the page: crop mode stays and no menu opens (#5835). No page took it, -/// which left crop mode. +/// 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(); @@ -7648,8 +7646,7 @@ fn a_right_click_on_a_crop_grip_past_the_page_edge_keeps_crop_mode() { } /// Crop mode's grips reach past the page's edge, where those of a picture filling its page lie -/// half: a drag from just outside the page's corner trims the picture. Only the half on the page took -/// the press. +/// 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(); diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index 8a74fea261..e5a588aaef 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -440,7 +440,7 @@ fn a_press_just_outside_a_picture_starts_an_area_selection() { } /// A double-click on one of the objects selected together leaves the group and reaches that one: -/// added text opens for typing. The double-click only left the group. +/// added text opens for typing. #[test] fn a_double_click_on_a_grouped_object_reaches_it() { let mut h = harness(); From 723667f29d41ea4c851cebcf8f3f6b7d41543b7b Mon Sep 17 00:00:00 2001 From: alehel Date: Wed, 4 Nov 2026 19:34:46 +0100 Subject: [PATCH 23/24] test(ui): pin the behaviour changes no test covered Found in review: the PR lists these and nothing pinned them. - With Prepare open, a drag from a highlight draws the selection rectangle. - A double-click on a paragraph selected with others opens it. - 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. - A crop grip takes a press within its reach, 9 px off. - A comment's menu opens over a picture and over a paragraph. - A right-click with the button held opens the selected comment's menu. Each seen failing against a planted violation. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/it/editing.rs | 55 +++++++++++++++++++++ crates/ui-egui/tests/it/object_selection.rs | 12 +++++ crates/ui-egui/tests/it/pointer.rs | 41 +++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 54c5397840..d71e3bfa85 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7499,6 +7499,61 @@ fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() { assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].rect, block.rect, "the paragraph stays"); } +/// 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"); +} + /// In the page grid, Delete deletes the page chosen there. The picture selected before the grid /// opened is not on screen, and stays. #[test] diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index e5a588aaef..e19f939e2e 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -451,3 +451,15 @@ fn a_double_click_on_a_grouped_object_reaches_it() { 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 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 0012d30d61..0a7dc80c63 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1607,6 +1607,7 @@ fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_the_comme 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, 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); @@ -1893,6 +1894,46 @@ 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 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] From fdc9f646f9147672d102cd006a572509459a6fa8 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 5 Nov 2026 08:51:44 +0100 Subject: [PATCH 24/24] fix(ui): a grouped double-click, held presses and blocked Add text, from review CodeRabbit's findings on #6243, each checked against the code: - A double-click on the objects selected together took its target from the hits at the release, so a release just off the paragraph pressed opened nothing. The router keeps what lay on top at the press with the objects aside (`under_objects`), as every owner acts where its press was. - A drag first seen on another page left the picked picture picked: that page is told `Elsewhere`, and the picture's test no longer ran there. - Delete with a button held removed the item, and the press held then named the next one. Delete waits for the button to come up, for pictures and added content. - A page just off the screen is still run for what lies near it, so a picture's keys acted there unseen. They ask whether the page is on screen. - Add text's drags end while its input is blocked, as Edit text's do. - With the page gaps hidden, a Shift-click on the row two pages share selected on the first page and the second cleared it. A press another page holds leaves the selection alone. Tests, each seen failing with its fix reverted: `a_double_click_on_a_grouped_paragraph_released_just_off_it_opens_it`, `a_drag_first_seen_on_the_next_page_lets_the_picked_picture_go`, `delete_waits_for_the_button_to_come_up` (each half), `a_selected_pictures_keys_wait_for_its_page_to_be_on_screen` (its continuous case), `a_paragraph_drag_ends_when_the_window_loses_focus` (Add text), `a_shift_click_where_two_pages_touch_keeps_what_it_selected`, `a_press_counts_the_modifiers_of_its_own_event`. `add_text_keeps_a_changed_paragraph_and_a_draft_when_it_moves_on` pins that a click on the page applies the open paragraph before a new box starts, so neither edit can take the other's place; seen failing with the draft's edit dropped. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app.rs | 4 +- crates/ui-egui/src/canvas/page_input.rs | 16 +- crates/ui-egui/src/canvas/page_router.rs | 12 +- crates/ui-egui/src/tools/content_ui.rs | 9 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 11 +- crates/ui-egui/src/tools/object_ui.rs | 4 +- crates/ui-egui/tests/it/editing.rs | 174 +++++++++++++++--- crates/ui-egui/tests/it/object_selection.rs | 41 +++++ crates/ui-egui/tests/it/pointer.rs | 27 +++ 9 files changed, 260 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index af588e6aaf..e5f2999229 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -595,9 +595,9 @@ impl eframe::App for PdfCraftApp { view.objects.set_input_blocked(shut); if shut { view.auto_scroll.cancel(); - // Edit text ignores input while it is blocked: its drags end rather than wait, to + // 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 self.quick_tool == QuickTool::EditText { + if matches!(self.quick_tool, QuickTool::EditText | QuickTool::AddText) { view.cancel_edit_text_gestures(); } } diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 0df0ebb5ea..42ff99022b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -129,13 +129,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let wants_under = editing || resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p, None)).unwrap_or_default(); let hovered = top(under.iter().cloned(), tool); - // A double-click on the objects selected together is the object's under it: the double-click - // leaves the group (`object_ui`), and added text opens for typing, a picture or a paragraph is - // the one taken (the user's ruling). + // 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() => { - top(under.iter().filter(|h| !matches!(h.owner, Owner::Objects { .. })).cloned(), tool) - .map_or(PagePress::Free, |h| PagePress::Mine(h.owner)) + view.router.under_objects().map_or(PagePress::Free, PagePress::Mine) } press => press, }; @@ -825,8 +824,11 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A press nobody holds picks the picture under it: a link's click follows the link only (ruling - // 5), though its drag or double-click still lets the picked picture go. - if tool == QuickTool::Select && matches!(input.press, PagePress::Free | PagePress::Mine(Owner::Link { .. })) && !input.consumed { + // 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); diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 463f315e0c..30a3f2126f 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -175,6 +175,9 @@ impl PagePress { 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. @@ -202,7 +205,9 @@ impl PageRouter { 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)); } @@ -216,6 +221,11 @@ impl PageRouter { } } + /// 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(in crate::canvas) fn keeps_drag(&self) -> bool { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index 581b102a41..a5cfbd4c2e 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -110,6 +110,8 @@ pub struct ContentView { 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])>, } @@ -404,6 +406,7 @@ pub(crate) struct ContentCx<'a> { 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; } @@ -634,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/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index b63f876298..07ca017798 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -467,14 +467,15 @@ fn apply_crop_mode(view: &mut DocView, image: &pdfcraft_engine::PageImage) { } } -/// The keys of the picture selected on `page`, a page being drawn, so the keys never reach a picture -/// off the screen, and the page grid and the Pages panel have had theirs. Delete or Backspace removes -/// it, wherever on the pages the pointer is, unless it is being dragged or cropped. In crop mode +/// 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() { + 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 }; @@ -492,7 +493,7 @@ pub(crate) fn picture_keys(ctx: &egui::Context, view: &mut DocView, page: usize, { s.crop = None; } - } else if !s.dragging() && !s.turning && (key(egui::Key::Delete) || key(egui::Key::Backspace)) && image.is_some() { + } 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); diff --git a/crates/ui-egui/src/tools/object_ui.rs b/crates/ui-egui/src/tools/object_ui.rs index b57f3ad79b..0ecfb6d29b 100644 --- a/crates/ui-egui/src/tools/object_ui.rs +++ b/crates/ui-egui/src/tools/object_ui.rs @@ -340,7 +340,9 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, press: PagePress, vie 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 Ok(objects) = s.inventory(doc, page) else { return false }; diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index d71e3bfa85..aaad18b48e 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -7485,6 +7485,7 @@ fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() { 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); @@ -7497,6 +7498,8 @@ fn add_text_moves_no_paragraph_while_it_holds_a_draft_it_cant_draw() { 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 @@ -7554,6 +7557,48 @@ fn a_crop_grip_takes_a_press_within_its_reach() { 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] @@ -7598,6 +7643,91 @@ fn a_selected_pictures_keys_wait_for_its_page_to_be_on_screen() { 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 @@ -7880,29 +8010,31 @@ fn an_add_text_click_pressed_beside_a_paragraph_starts_a_box_there() { /// waited, and moved the paragraph to wherever the pointer then was. #[test] fn a_paragraph_drag_ends_when_the_window_loses_focus() { - let mut h = edit_page(pdfcraft_ui_egui::QuickTool::EditText); - 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)); + 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)); } - 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 diff --git a/crates/ui-egui/tests/it/object_selection.rs b/crates/ui-egui/tests/it/object_selection.rs index e19f939e2e..21ebc90b69 100644 --- a/crates/ui-egui/tests/it/object_selection.rs +++ b/crates/ui-egui/tests/it/object_selection.rs @@ -452,6 +452,47 @@ fn a_double_click_on_a_grouped_object_reaches_it() { 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() { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 0a7dc80c63..e0021aa7be 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1934,6 +1934,33 @@ fn a_right_click_with_the_button_held_opens_the_selected_comments_menu() { 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]