diff --git a/CLAUDE.md b/CLAUDE.md index 5e9328f8d3..5c7034b7a4 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 router says the press is its own (`view.router.owner(page)`), never by testing the press point again or by asking 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_modules_take_presses_from_the_router_without_repeating_hit_tests` guards the centralized hit calls and captured-owner handlers; runtime routing tests cover gesture behavior. +- **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. - **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 c07b226d0d..be0b652c86 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; -use crate::page_cursor::{Layer, Subject}; +use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -28,6 +28,7 @@ pub(super) mod image_pick; mod navigation; mod organize; pub(super) mod page_cursor; +mod page_input; pub(super) mod page_pointer; pub(super) mod page_router; pub mod scroll_speed; @@ -62,6 +63,8 @@ pub(crate) use organize::grid_page_rect; use organize::organize_grid; #[cfg(test)] use organize::sharp_enough; +pub(crate) use page_input::PageToolContext; +use page_input::{PageInput, cancel_on_escape, page_overlays, page_tool_input, text_layer}; use selection::{Selection, Span, click_unit, selection_actions}; pub use xform::PageXform; use xform::snap_to_pixels; @@ -887,6 +890,9 @@ impl DocView { self.sign.drag = None; self.redact_drag = None; self.crop_drag = None; + if let Some(q) = &mut self.camera { + q.grab = None; + } self.block_drag = None; self.org_drag = None; self.panel_drag = None; @@ -1235,6 +1241,19 @@ struct PageFrame<'a> { current_height: f32, pressed_page: Option, calc_hover: Option, + /// A page took this frame's right-click. + right_clicked: bool, +} + +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))?; + self.right_clicked = true; + Some(p) + } } /// Visible heights (points) closer than this are equal: one page size can meet the viewport a @@ -1478,308 +1497,6 @@ fn paint_page_tiles( } } -/// What the page's tools did with the pointer this frame. -struct PageInput { - /// A tool, a field or a comment took the pointer: text selection must leave it alone. - consumed: bool, - /// Redact or Highlight draws boxes off text. - area_tool: bool, -} - -/// Common geometry and pointer state for a tool acting on one page. -#[derive(Clone, Copy)] -pub(crate) struct PageToolContext<'a> { - pub ui: &'a egui::Ui, - pub resp: &'a egui::Response, - pub xf: &'a PageXform, - pub ptr: &'a PagePointer, - pub page: usize, - pub doc: &'a pdfcraft_engine::Document, - pub info: &'a DocInfo, -} - -/// Comments: tools, selection, moving and resizing come before text selection. Form fields take -/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then -/// comments; then text selection. -fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { - let PageEnv { - added, - allowed, - author, - by_line, - can_crop, - can_fill, - can_modify, - comments_hidden, - custom_stamp, - date_text, - doc, - doc_links, - editing_content, - form, - info, - initials, - prefs, - preparing, - redact_mark, - signature, - table_preview, - text_style, - tool, - units, - fill_text_size, - fill_text_color, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let 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 }; - let (lines, images) = - if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); - let hits = PageHits { - page: i, - xf: &xf, - info, - tool, - pcx: &pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines: &lines, - images: &images, - clip: ui.clip_rect(), - }; - if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { - let at = hits.at(view, o); - view.router.press(i, at, tool); - } - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - frame.out.stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - // The tool's own click: nothing drawn over the page here holds the press. - if resp.clicked() && view.router.owner(i).is_none() { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - frame.out.stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - cx, - crate::fill_sign::PageInput { - tool: ft, - text_size: fill_text_size, - text_color: fill_text_color, - signature: signature.as_ref(), - initials: initials.as_ref(), - preview: frame.signature_preview, - author, - date_text, - }, - view, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify); - } - if tool == QuickTool::Crop - && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop }) - { - view.current = i; - frame.out.open_boxes = true; - } - let on_field = if preparing { - let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, view); - frame.out.field_props |= o.properties; - frame.out.field_placed |= o.placed; - frame.out.open_table = frame.out.open_table.or(o.table); - o.consumed || field_tool.is_some() - } else { - tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, view) - }; - // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). - let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; - let boxing = area_tool && { - 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])) - }; - crate::redact_ui::page_input(cx, over_text, view) - }; - let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); - // Add text acts on the page's own paragraphs over them, and on the one its press was on until - // the button is up: a drag that leaves the box still ends, and no new box takes it over. - let over_source = tool == QuickTool::AddText - && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) - || pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) - })); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - 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 - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); - let image_cx = crate::edit_text_ui::ImageCx { - ui, - resp, - xf: &xf, - ptr, - page: i, - info, - allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), - }; - let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); - !others_own_pointer && (text_used || image_used) - }; - if tool == QuickTool::EditText { - if others_own_pointer { - if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { - view.image_selection = None; - } - } else { - crate::content_ui::sync_image_selection(view, i, &images, added); - } - } - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); - // The cursor and the tip from what lies under the pointer, after the tools claimed theirs (the - // last claim on a layer stands). - if frame.env.unobstructed - && let Some(p) = pointer - { - for h in hits.at(view, p) { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip); - } - } - PageInput { consumed, area_tool } -} - -/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. -fn edit_inventory( - view: &mut DocView, - doc: &pdfcraft_engine::Document, - i: usize, -) -> (Vec, Vec) { - let generation = doc.edit_generation(); - let lines = match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - }; - let images = cached_edit_images(view, doc, i).to_vec(); - (lines, images) -} - /// Images parsed once per page and document edit generation, shared by painting and menus. fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] { let generation = doc.edit_generation(); @@ -1790,478 +1507,6 @@ fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document &entry.1 } -/// What the page modules have under a point on one page, for the page router. -struct PageHits<'a> { - page: usize, - xf: &'a PageXform, - info: &'a DocInfo, - tool: QuickTool, - pcx: &'a comments::PageCx<'a>, - form: &'a [pdfcraft_engine::FormField], - can_fill: bool, - can_modify: bool, - /// Edit a PDF is open: added text and pictures can be picked up. - editing_content: bool, - preparing: bool, - field_tool: Option, - /// Prepare may draw and move fields (not while a table is being set up). - prepare_allowed: bool, - added: &'a [pdfcraft_engine::Added], - lines: &'a [pdfcraft_engine::TextBlock], - images: &'a [pdfcraft_engine::PageImage], - /// The pages' visible area (the picture's rotate knob stays inside it). - clip: Rect, -} - -impl PageHits<'_> { - /// 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; then form fields - /// (Prepare's, being moved, over the comments; filled ones under added content and the - /// comments that move). - fn at(&self, view: &DocView, p: Pos2) -> Vec { - let PageHits { - page, - xf, - info, - tool, - pcx, - form, - can_fill, - can_modify, - editing_content, - preparing, - field_tool, - prepare_allowed, - added, - lines, - images, - clip, - } = *self; - // Crop mode owns presses on its page, including the controls beside its - // frame. Underlying paragraphs and the empty-page marquee must not take - // a crop button's press before the image module receives it. - if tool == QuickTool::EditText - && can_modify - && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some()) - { - return vec![crate::page_router::Hit::new( - crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body }, - crate::page_cursor::Layer::Handle, - egui::CursorIcon::Default, - )]; - } - let fields = if preparing { - crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) - } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) - } else { - None - }; - // With Add text, only the paragraphs and pictures not under added content. - let editing = - can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); - let (pictures, paragraphs, objects, grips) = if editing { - let open = view.line_editor.as_ref().filter(|e| e.page == page); - let frame = open - .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. - let edit_text = tool == QuickTool::EditText; - let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); - ( - edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), - on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), - edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), - edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), - ) - } else { - (None, None, None, None) - }; - // Added text and pictures, over the page's own paragraphs and under the objects selected - // together. - let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); - let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); - [link_hit(info, xf, page, p), comments::hit(pcx, view, p), pictures, paragraphs, added, objects, grips, fields] - .into_iter() - .flatten() - .collect() - } -} - -/// Text layer: find matches, selection, I-beam and drag-to-select. -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 PageSlot { i, r, xf } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - if let Some(text) = view.texts.get(&i).cloned() { - paint_text_marks(ui, view, &text, i, xf, resp.dragged()); - if selects_text - && !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 }); - } - } -} - -fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { - let painter = ui.painter(); - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(find) = &view.find { - for (index, (match_page, range)) in find.matches.iter().enumerate() { - if *match_page != page { - continue; - } - let fill = if find.current == Some(index) { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - for line_rect in text.line_rects(range.clone()) { - painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for line_rect in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(selection) = view.selection.filter(|selection| selection.page == page) { - for line_rect in selection.rects(text) { - painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - if let Span::Column { rect } = selection.span - && dragging - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } -} - -struct TextPointer<'a> { - page: usize, - xf: PageXform, - rect: Rect, - info: &'a DocInfo, - tool: QuickTool, - resp: &'a egui::Response, - ptr: PagePointer, - pointer: Pos2, -} - -struct TextGesture<'a> { - page: usize, - xf: PageXform, - rect: Rect, - tool: QuickTool, - ptr: PagePointer, - resp: &'a egui::Response, - pointer: Pos2, - vx: f32, - vy: f32, - over_text: bool, - over_link: 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 (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 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 }; - update_text_cursor(view, &gesture); - update_selection_on_press(ui, view, text, &gesture); - update_dragged_selection(view, text, &gesture); - finish_text_pointer_gesture(ui, view, text, &gesture); -} - -fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { - // A press on a link selects nothing (it is the link's), so no cursor here says it would. - if gesture.column && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); - } else if gesture.over_text && !gesture.over_link { - view.cursor.claim(Layer::Text, egui::CursorIcon::Text); - } -} - -fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - // 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() { - 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); - view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(gesture.pointer); - let clicks = selection_click_count(view.clicks, gesture.tool); - if clicks >= 2 - && gesture.over_text - && 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)); - } - } - if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { - view.actions_dismissed = None; - view.selection_drag = true; - let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); - let (ox, oy) = gesture.xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if gesture.column { - Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) - } else { - text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) - }; - } - } -} - -fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { - if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } -} - -fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let clicks = selection_click_count(view.clicks, gesture.tool); - if gesture.resp.dragged() - && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) - { - match &mut selection.span { - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) - && let Some((first, last)) = click_unit(text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { - *head = hit; - } - } - Span::Column { rect } => { - rect[2] = gesture.vx; - rect[3] = gesture.vy; - } - } - } - if gesture.resp.drag_stopped() - && view.selection.is_some_and(|selection| { - selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() - }) - { - view.selection = None; - } -} - -fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { - let release_clicks = if gesture.resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if gesture.resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = selection_click_count(view.clicks, gesture.tool); - if release_clicks > 0 - && clicks >= 2 - && gesture.over_text - && view.selection.is_none() - && 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() { - 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), - text.nearest(gesture.vx, gesture.vy), - ) { - (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, - _ => view.selection = None, - } - } -} - -/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks -/// and badges. -fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { - let PageEnv { - added, - allowed, - calc_order, - can_modify, - comments_hidden, - doc, - doc_links, - editing_content, - form, - generation, - info, - prefs, - preparing, - redact_mark, - table_preview, - tool, - unobstructed, - .. - } = frame.env; - let PageSlot { i, xf, .. } = *slot; - let PageFrame { resp, ptr, pointer, .. } = *frame; - let painter = ui.painter(); - let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; - let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if tool == QuickTool::Select && !input.consumed { - crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { - comments::page_after_text(resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(resp, i, info, view); - } - } - if input.area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(tool_cx, form, view); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { - frame.calc_hover = Some(field); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); - } - - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); - // Links: the hand and the link's target, which count only for the tools that follow or - // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is - // open, even beside it. A click follows the link its press was on, released over it: one that - // slips off the link first follows nothing, as a button's does. - if unobstructed - && resp.clicked() - && tool == QuickTool::Select - && !input.consumed - && let Some(owner @ crate::page_router::Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| link_hit(info, &xf, i, p)).is_some_and(|h| h.owner == owner) - && let Some(l) = info.links.get(index) - { - frame.out.clicked_link = Some(l.target.clone()); - } - // Comments say what they are with any tool, beside its cursor, as Acrobat's do. - comments::describe(&pcx, view); - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); - let mark = Rect::from_center_size(sr.center(), vec2(side, side)); - icons::paint(ui, mark, icon, side * 0.64, color); - // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with - // any tool; a link's or a comment's tip wins over any badge. - if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { - view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); - } - } -} - -fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { - if !enabled { - return; - } - for field in form.iter() { - let required = field.has(pdfcraft_engine::field_flags::REQUIRED); - for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { - let rect = widget.rect; - let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if widget.round { - let radius = screen_rect.width().min(screen_rect.height()) / 2.0; - painter.circle_filled(screen_rect.center(), radius, tint); - painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(screen_rect, CornerRadius::same(1), tint); - painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } -} - fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; @@ -2300,10 +1545,18 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; - let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + // 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) { + comments::anchor(&pcx, p, view); + } + PageInput { consumed: true, area_tool: false, hover: None, released_over: false } + } else { + page_tool_input(ui, view, frame, &slot) + }; + let painter = ui.painter(); let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); @@ -2323,43 +1576,6 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i page_overlays(ui, view, frame, &slot, &input); } -/// The link under `p` on `page`: the hand, and where a click goes. Only the Select tool follows -/// links and the Link tool edits them (`Layer::reached_by`). -fn link_hit(info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { - let (index, l) = info.links.iter().enumerate().rfind(|(_, l)| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; - let label = match &l.target { - LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - let owner = crate::page_router::Owner::Link { index }; - Some(crate::page_router::Hit { tip: Some(label), ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) }) -} - -/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is -/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The -/// frame of the release still belongs to the cancelled press. -fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { - // Over once the button is up and its release seen, or when a new press comes (its release and - // the next press can share a frame). - let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); - if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { - view.press_cancelled = false; - } - if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) - { - // A text selection being dragged goes with its drag: the markup, redaction or Replace - // Text that its end would make is not made. - if view.selection_drag { - view.clear_selection(); - } - view.cancel_pointer_gestures(); - view.press_cancelled = true; - } -} - #[allow(clippy::cognitive_complexity)] fn draw_pages( ui: &mut egui::Ui, @@ -2445,9 +1661,9 @@ fn draw_pages( cancel_on_escape(ui.ctx(), view, &ptr); view.router.settle(ptr.any_down, !ptr.edges.is_empty()); // A new press is no longer the last one's, wherever it lands (the page it is on gives it - // its owner; in the gap between pages it has none). + // its owner; in the gap between pages it is no page's). if ptr.pressed().is_some() { - view.router.cancel(); + view.router.begin(); // The page widget is not focusable. Explicitly return keyboard ownership from Pages // when the user presses the document, without stealing focus through a modal. if let Some(id) = view.pages_focus_id { @@ -2462,6 +1678,11 @@ fn draw_pages( if resp.secondary_clicked() { // Set again below when the right-click lands on a link. 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. + view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { let pointer_pos = ptr.pos; @@ -2526,13 +1747,28 @@ fn draw_pages( current_height: f32::INFINITY, pressed_page: None, calc_hover: None, + right_clicked: false, }; for &i in visible_pages { draw_page(ui, view, &mut frame, i); } let PageFrame { - wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + wanted, + visible_now, + mut current, + best_overlap, + current_overlap, + current_height, + pressed_page, + calc_hover, + out: page_out, + right_clicked, + .. } = frame; + // A right-click no page took deselects the comment, so the page's menu opens, not the comment's. + if resp.secondary_clicked() && !right_clicked { + view.comments.selected = None; + } let PageOut { mut canvas_action, clicked_link, @@ -2701,7 +1937,9 @@ fn draw_pages( })); if is_image_menu && let Some(sel) = view.image_selection.clone() { let images = cached_edit_images(view, doc, sel.page).to_vec(); - let at = view.comments.context_at.map(|(_, at)| at); + // 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. + 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; diff --git a/crates/ui-egui/src/canvas/crop.rs b/crates/ui-egui/src/canvas/crop.rs index bdd1e9e135..7858b00dd6 100644 --- a/crates/ui-egui/src/canvas/crop.rs +++ b/crates/ui-egui/src/canvas/crop.rs @@ -27,10 +27,12 @@ pub(crate) struct CropPageInput<'a> { pub(crate) info: &'a DocInfo, pub(crate) view: &'a mut DocView, pub(crate) allowed: bool, + /// Nobody holds the press, and it began on this page: the tool's. + pub(crate) free: bool, } pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { - let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input; + let CropPageInput { resp, xf, ptr, page, info, view, allowed, free } = input; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); @@ -38,9 +40,9 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool { if !allowed { return false; } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.crop_drag = Some((page, o)); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs new file mode 100644 index 0000000000..03bd3e807d --- /dev/null +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -0,0 +1,900 @@ +//! Page input: what the tools do with the pointer on one page, what lies under it for the page +//! router, the text layer, and what is drawn over the page after its text. + +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use pdfcraft_render::{DocInfo, PageText}; + +use super::{DocView, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, signature_badges}; +use crate::page_cursor::{Layer, Subject}; +use crate::page_pointer::PagePointer; +use crate::page_router::{Owner, PagePress, top}; +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. + pub(super) consumed: bool, + /// Redact or Highlight draws boxes off text. + pub(super) area_tool: bool, + /// What a press under the pointer would take (the top hit there), for hover painting. + pub(super) hover: Option, + /// The press's click came up over what the press began on: a link's or a field's click counts + /// only then, as a button's does. + pub(super) released_over: bool, +} + +/// Common geometry and pointer state for a tool acting on one page. +#[derive(Clone, Copy)] +pub(crate) struct PageToolContext<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub doc: &'a pdfcraft_engine::Document, + pub info: &'a DocInfo, +} + +/// 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. +pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + can_fill, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + info, + prefs, + preparing, + redact_mark, + table_preview, + text_style, + tool, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let 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 }; + let (lines, images) = + if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { edit_inventory(view, doc, i) } else { Default::default() }; + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let hits = PageHits { + page: i, + xf: &xf, + info, + tool, + pcx: &pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + doc_links, + lines: &lines, + images: &images, + clip: ui.clip_rect(), + }; + if let Some(o) = ptr.pressed().filter(|o| xf.rect.contains(*o)) { + let at = hits.at(view, o); + view.router.press(i, at, tool); + } + // What this page has to do with the press: its owner's, the active tool's, or another page's. + let press = view.router.press_for(i); + // What lies under the pointer before the tools act, on the frames that need it: for a click's + // release, a right-click's tool checks, and Fill & Sign's preview. + let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); + let released_over = match press { + PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), + _ => false, + }; + // A right-click while the primary button is held (a comment moved, a box drawn) leaves the + // selections that press acts on alone. egui ends the press's own interaction when any button is + // released, the right-click's own frame included, so the button is what tells. A touch's long + // press, which egui reports as a right-click with the finger still down, opens its menu. + let press_held = ptr.primary_down && !resp.long_touched(); + let context = frame.right_click_on(xf.rect).and_then(|p| right_click(&hits, &under, view, p, press_held)); + // A mark already placed takes its own press (comments, below), as does one being moved on this + // page until its move ends; with no button down the Fill & Sign tool's preview hides where a + // press would be taken (a mark, its handles, a Prepare field). + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + 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); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + o.consumed || field_tool.is_some() + } else { + tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view) + }; + // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). + let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; + let boxing = area_tool && { + 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])) + }; + crate::redact_ui::page_input(cx, over_text, press == PagePress::Free, view) + }; + let on_objects = tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(cx, view); + // Add text acts on the page's own paragraphs over them, and on the one its press was on until + // the button is up: a drag that leaves the box still ends, and no new box takes it over. + let over_source = tool == QuickTool::AddText + && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Paragraph { .. })) + || pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) && crate::edit_text_ui::over_paragraph(&xf, info, i, &lines, p) + })); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } + 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 + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(cx, &lines, view, !others_own_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view); + !others_own_pointer && (text_used || image_used) + }; + if tool == QuickTool::EditText { + if others_own_pointer { + if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() { + view.image_selection = None; + } + } else { + crate::content_ui::sync_image_selection(view, i, &images, added); + } + } + let (link_owner, link_context) = (link_of(press.owner()), link_of(context)); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, press == PagePress::Free, link_owner, link_context, view); + if tool == QuickTool::Select + && can_modify + && let Some(index) = link_context + { + view.links.context = Some((i, index)); + } + // 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. + let taken = on_objects || on_edit_text || on_link || on_content || boxing || on_field; + let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); + let consumed = taken || on_comment; + let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); + PageInput { consumed, area_tool, hover, released_over } +} + +/// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, +/// for hover painting. With `claim` (nothing drawn over the pages, such as the palette), its cursor +/// and tip too, after the tools claimed theirs (the last claim on a layer stands). +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2, claim: bool) -> Option { + let under = hits.at(view, p); + if claim { + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + } + } + top(under, hits.tool) +} + +/// The link `owner` is, by its `/Annots` index. +fn link_of(owner: Option) -> Option { + match owner { + Some(Owner::Link { index }) => Some(index), + _ => None, + } +} + +/// The active tool's own input on page `i`: the one gate for presses nobody holds. Measure, the +/// stamps, Fill & Sign, Sign area, zoom and snapshot, the camera's quadrilateral and crop act only +/// on a press that began on this page and that no hit won (`press` is Free), and keep their cursor +/// and gestures. The camera also drags a corner whose press it holds, and crop's double-click +/// opens Set Page Boxes wherever it is. `over_mark`: with Fill & Sign, a hit holds the press (a +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a press +/// under the pointer would be taken (a mark, its handles, a Prepare field). +fn tool_input( + ui: &egui::Ui, + view: &mut DocView, + frame: &mut PageFrame<'_>, + slot: &PageSlot, + pcx: &comments::PageCx<'_>, + press: PagePress, + over_mark: bool, +) { + let PageEnv { + allowed, + author, + by_line, + can_crop, + can_modify, + custom_stamp, + date_text, + doc, + info, + initials, + signature, + tool, + units, + fill_text_size, + fill_text_color, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, .. } = *frame; + let cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let free = press == PagePress::Free; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, crate::measure_ui::Clicks::of(resp, free), doc, view, pcx, measure_tool, units); + } + if let QuickTool::Stamp(kind) = tool + && allowed + && crate::stamps_ui::place(cx, kind, by_line, author, free, view) + { + frame.out.stamp_placed = true; + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && crate::stamps_ui::place_custom(cx, cs, author, free, view) + { + frame.out.stamp_placed = true; + } + if let QuickTool::Fill(ft) = tool + && allowed + && !over_mark + { + let input = crate::fill_sign::PageInput { + tool: ft, + text_size: fill_text_size, + text_color: fill_text_color, + signature: signature.as_ref(), + initials: initials.as_ref(), + preview: frame.signature_preview, + author, + date_text, + }; + match crate::fill_sign::page_input(cx, input, free, view) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(cx, free, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, free, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify, press); + } + if tool == QuickTool::Crop + && crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop, free }) + { + view.current = i; + frame.out.open_boxes = true; + } +} + +/// 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. +/// `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. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2, press_held: bool) -> Option { + 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); + 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. + 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(), + }; + if !tool_takes_it { + let comment = match context { + Some(Owner::Comment { index, .. }) => Some(index), + _ => None, + }; + comments::context(hits.pcx, comment, view); + } + if let Some(Owner::PrepareField { field, widget }) = context { + crate::prepare::context(hits.form, field, widget, view); + } + context +} + +/// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. +fn edit_inventory( + view: &mut DocView, + doc: &pdfcraft_engine::Document, + i: usize, +) -> (Vec, Vec) { + let generation = doc.edit_generation(); + let lines = match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + }; + let images = cached_edit_images(view, doc, i).to_vec(); + (lines, images) +} + +/// What the page modules have under a point on one page, for the page router. +struct PageHits<'a> { + page: usize, + xf: &'a PageXform, + info: &'a DocInfo, + tool: QuickTool, + pcx: &'a comments::PageCx<'a>, + form: &'a [pdfcraft_engine::FormField], + can_fill: bool, + can_modify: bool, + /// Edit a PDF is open: added text and pictures can be picked up. + editing_content: bool, + preparing: bool, + field_tool: Option, + /// Prepare may draw and move fields (not while a table is being set up). + prepare_allowed: bool, + added: &'a [pdfcraft_engine::Added], + /// The document's links, as the engine lists them (the Link tool edits every one). + doc_links: &'a [pdfcraft_engine::LinkItem], + lines: &'a [pdfcraft_engine::TextBlock], + images: &'a [pdfcraft_engine::PageImage], + /// The pages' visible area (the picture's rotate knob stays inside it). + clip: Rect, +} + +impl PageHits<'_> { + /// The hits at `p`, bottom up ([`Self::hits`]). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + self.hits(view, p, false) + } + + /// 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`]). + /// 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. + fn context_at(&self, view: &DocView, p: Pos2, under: &[crate::page_router::Hit]) -> Option { + if self.tool == QuickTool::Link + && self.can_modify + && let Some(press) = top(under.iter().cloned(), QuickTool::Link) + { + return Some(press.owner); + } + top(self.hits(view, p, true), 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. + /// 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 { + let PageHits { + page, + xf, + info, + tool, + pcx, + form, + can_fill, + can_modify, + editing_content, + preparing, + field_tool, + prepare_allowed, + added, + doc_links, + lines, + 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]; + } + let fields = if preparing { + crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) + } else if tool == QuickTool::Select { + crate::forms_ui::hit(xf, info, page, form, can_fill, p) + } else { + None + }; + // With Add text, only the paragraphs and pictures not under added content. + let editing = + can_modify && (tool == QuickTool::EditText || tool == QuickTool::AddText && !crate::content_ui::hits_added(xf, info, page, added, p)); + let (pictures, paragraphs, objects, grips) = if editing { + let open = view.line_editor.as_ref().filter(|e| e.page == page); + let frame = open + .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. + let edit_text = tool == QuickTool::EditText; + let on_paragraphs = edit_text || crate::edit_text_ui::over_paragraph(xf, info, page, lines, p); + ( + edit_text.then(|| crate::edit_text_ui::picture_hit(xf, page, info, images, view, p)).flatten(), + on_paragraphs.then(|| crate::edit_text_ui::hit(xf, info, page, lines, open.map(|e| e.block), p)).flatten(), + edit_text.then(|| crate::object_ui::hit(view, xf, info, page, frame, p)).flatten(), + edit_text.then(|| crate::edit_text_ui::picture_handle_hit(clip, xf, page, info, images, view, p)).flatten(), + ) + } else { + (None, None, None, None) + }; + // Added text and pictures, over the page's own paragraphs and under the objects selected + // together. + let picks_up = matches!(tool, QuickTool::Select | QuickTool::AddText | QuickTool::EditText); + let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); + let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); + let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; + [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() + } +} + +/// 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 PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !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 }); + } + } +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); + } + } + } + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: 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 (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 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 }; + update_text_cursor(view, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) { + // A press on a link selects nothing (it is the link's), so no cursor here says it would. + if gesture.column && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair); + } else if gesture.over_text && !gesture.over_link { + view.cursor.claim(Layer::Text, egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + // 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() { + 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); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && 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)); + } + } + if gesture.resp.drag_started() && press_here && !view.router.keeps_drag() { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; + } + } +} + +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } + } + } + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} + +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) + } else { + 0 + }; + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && 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() { + 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), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let tool_cx = PageToolContext { ui, resp, xf: &xf, ptr, page: i, doc, info }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + // A cancelled press ends no selection: no markup or redaction is made from it. + if !view.press_cancelled { + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view, input.hover.as_ref()); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(tool_cx, form, view); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(field) = crate::calc_order_ui::paint_page(tool_cx, form, &view.calc_order, generation, selected) { + frame.calc_hover = Some(field); + } + } + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, input.hover.as_ref()); + crate::forms_ui::tooltip(resp, form, input.hover.as_ref()); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // Links: the hand and the link's target, which count only for the tools that follow or + // edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is + // open, even beside it. A click follows the link its press was on, released over it: one that + // slips off the link first follows nothing, as a button's does. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && let Some(Owner::Link { index }) = view.router.owner(i) + && input.released_over + && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) + { + frame.out.clicked_link = Some(l.target.clone()); + } + // Comments say what they are with any tool, beside its cursor, as Acrobat's do. + comments::describe(&pcx, view); + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + // Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with + // any tool; a link's or a comment's tip wins over any badge. + if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) { + view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string()); + } + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +/// Escape while the pages hold a press abandons the gesture it drives, whichever tool's: nothing is +/// placed, drawn, moved, resized or opened, and the press is ignored until the button is up. The +/// frame of the release still belongs to the cancelled press. +pub(super) fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer) { + // Over once the button is up and its release seen, or when a new press comes (its release and + // the next press can share a frame). + let new_press = matches!(ptr.edges.last(), Some(crate::page_pointer::Edge::Press(_))); + if view.press_cancelled && (new_press || !ptr.any_down && ptr.edges.is_empty()) { + view.press_cancelled = false; + } + if !view.press_cancelled && ptr.origin.is_some() && ptr.primary_down && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + // A text selection being dragged goes with its drag: the markup, redaction or Replace + // Text that its end would make is not made. + if view.selection_drag { + view.clear_selection(); + } + view.cancel_pointer_gestures(); + view.press_cancelled = true; + } +} diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 02cb2208ed..38b028de24 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -39,6 +39,9 @@ pub(crate) struct PagePointer { pub origin: Option, /// This frame's primary presses and releases of a press the pages hold, in order. pub edges: Vec, + /// Where this frame's last primary release of a press the pages hold was: where a click came + /// up, which the pointer may have left by the frame's end. Kept past the cap on `edges`. + pub released: Option, /// The primary button is down, wherever it was pressed: a gesture ends once it is up. pub primary_down: bool, /// Some button is down, wherever it was pressed. @@ -49,8 +52,16 @@ 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, primary_down, any_down) = - ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events), i.pointer.primary_down(), i.pointer.any_down())); + let (at, origin, edges, released, primary_down, any_down) = ui.input(|i| { + ( + i.pointer.interact_pos(), + i.pointer.press_origin(), + edges(&i.events), + last_release(&i.events), + i.pointer.primary_down(), + i.pointer.any_down(), + ) + }); // egui's own answer to "do the pages hold a press?": from the press on until the frame // of its click, release or drag stop. A drag can outlive egui's press while the // pointer is outside the window. With a dialog open only a drag counts, up to the frame @@ -64,9 +75,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(), primary_down, any_down }; + return Self { pos, origin: None, edges: Vec::new(), released: None, primary_down, any_down }; } - Self { pos, origin: press_origin, edges, primary_down, any_down } + Self { pos, origin: press_origin, edges, released, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. @@ -97,6 +108,14 @@ fn first_press(edges: &[Edge]) -> Option { }) } +/// 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 { + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, .. } if pos.is_finite() => Some(*pos), + _ => None, + }) +} + /// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. fn edges(events: &[Event]) -> Vec { events diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2cbde2b995..3f5d1e105f 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -53,6 +53,20 @@ fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); } +/// A click is located where it came up even after a stall's many presses and releases, past the +/// cap on a frame's edges. Read from the edges kept, it was an earlier release's. +#[test] +fn a_frames_last_release_is_kept_past_the_cap_on_its_edges() { + let ctx = Context::default(); + let (p, q) = (pos2(50.0, 60.0), pos2(80.0, 90.0)); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let mut events: Vec = (0..2 * MAX_EDGES).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + events.extend([button(q, PointerButton::Primary, true), button(q, PointerButton::Primary, false)]); + let stall = frame(&ctx, events, false); + assert_eq!(stall.edges.len(), MAX_EDGES); + assert_eq!(stall.released, Some(q)); +} + #[test] fn a_press_and_release_in_one_frame_keep_the_press() { let ctx = Context::default(); @@ -150,8 +164,9 @@ fn edges_are_primary_finite_in_order_and_capped() { } /// The page modules: the per-page chain in `canvas::document_area` and what it calls. -const PAGE_MODULES: [&str; 35] = [ +const PAGE_MODULES: [&str; 37] = [ "canvas.rs", + "canvas/page_input.rs", "canvas/area.rs", "canvas/bars.rs", "canvas/cache.rs", @@ -186,8 +201,18 @@ const PAGE_MODULES: [&str; 35] = [ "canvas/image_pick.rs", "canvas/signature_drag.rs", "tools/calc_order_ui.rs", + "tools/stamps_ui.rs", ]; +/// 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. +const PAGE_MODULE_MARKS: [&str; 6] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::", ".router."]; + +/// The router's own files, which define what page modules use. +const ROUTER_FILES: [&str; 3] = ["canvas/page_pointer.rs", "canvas/page_router.rs", "canvas/page_cursor.rs"]; + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. const RAW_READS: [&str; 21] = [ "hover_pos()", @@ -216,14 +241,13 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 11] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), ("canvas/area.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), - ("tools/comments/input.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), @@ -263,36 +287,131 @@ fn uses(line: &str, name: &str) -> bool { line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) } +/// The lines of `source` outside its test modules, numbered from 1. Tests drive egui's input and +/// read its output themselves. A test module (`#[cfg(test)]` then `mod name {`) ends at the first +/// line that is a lone `}`, as rustfmt lays a top-level item out, so the code after a test module +/// in the middle of a file is still read; `#[cfg(test)]` then `mod name;` is one line. +fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { + let mut kept = Vec::new(); + let mut lines = source.lines().enumerate().map(|(n, line)| (n + 1, line)).peekable(); + while let Some((n, line)) = lines.next() { + if line == "#[cfg(test)]" && lines.peek().is_some_and(|(_, next)| next.starts_with("mod ")) { + if lines.next().is_some_and(|(_, module)| module.ends_with('{')) { + lines.by_ref().find(|(_, line)| *line == "}"); + } + continue; + } + kept.push((n, line)); + } + kept +} + +/// The lines of code in `source` outside its test modules, numbered from 1, each with the +/// top-level function or `impl` method it is in; comment lines are left out. A line rustfmt +/// continued (a method chain split as `view` / `.router` / `.owner(i)`) is read with the one it +/// continues, under that one's number. +fn code_lines(source: &str) -> Vec<(usize, Option<&str>, String)> { + let (mut function, mut in_impl) = (None, false); + let mut lines: Vec<(usize, Option<&str>, String)> = Vec::new(); + for (n, line) in outside_test_modules(source) { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + match lines.last_mut() { + Some((_, _, text)) if line.trim_start().starts_with('.') => text.push_str(line.trim_start()), + _ => lines.push((n, function, line.to_owned())), + } + } + lines +} + +/// Each name `find` finds in a line of the page modules' code (their tests left out), as (file, +/// the top-level function or `impl` method it is in, line, name) ([`code_lines`]). +fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut found = Vec::new(); + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); + for (n, function, text) in code_lines(&source) { + for name in find(&text) { + found.push((file, function.map(str::to_owned), n, name)); + } + } + } + found +} + +/// Each hit test `source` imports under a name that is not a hit test's (`use …::hit as under;`), +/// as (name, alias), from each `use` statement, from its first line to its `;`, wherever it is. +fn aliased_hit_tests(source: &str) -> Vec<(String, String)> { + let mut found = Vec::new(); + let mut statement: Option = None; + for line in source.lines().map(str::trim) { + if statement.is_none() && ["use ", "pub use ", "pub(crate) use ", "pub(super) use "].iter().any(|start| line.starts_with(start)) { + statement = Some(String::new()); + } + let Some(text) = statement.as_mut() else { continue }; + text.push_str(line); + text.push(' '); + if !line.ends_with(';') { + continue; + } + for part in text.split([',', '{', '}', ';']) { + let Some((path, alias)) = part.split_once(" as ") else { continue }; + let (name, alias) = (path.trim().rsplit("::").next().unwrap_or_default(), alias.trim()); + if is_hit_test(name) && !is_hit_test(alias) { + found.push((name.to_owned(), alias.to_owned())); + } + } + statement = None; + } + found +} + +/// Each hit test a page module imports under a name that is not a hit test's, which the checks by +/// name would not see ([`aliased_hit_tests`]). +fn hit_test_aliases() -> Vec { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut found = Vec::new(); + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module"); + for (name, alias) in aliased_hit_tests(&source) { + found.push(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: name \ + it `hit` or `…_hit`." + )); + } + } + found +} + +/// Each use of one of `names` in the page modules' code (their tests left out). +fn page_module_uses(names: &[&str]) -> Vec<(&'static str, Option, usize, String)> { + page_module_matches(|line| names.iter().filter(|name| uses(line, name)).map(|name| (*name).to_owned()).collect()) +} + /// Each use of one of `names` in the page modules' code (their tests left out) outside the /// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses /// none of them any more, as "src/file: fn function unused". fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { - let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + misuses_in(page_module_uses(names), allowed, why, unused) +} + +/// [`misuses`], of what was `found`. +fn misuses_in(found: Vec<(&str, Option, usize, String)>, allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec { let mut problems = Vec::new(); let mut needed = vec![false; allowed.len()]; - for file in PAGE_MODULES { - let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); - // Tests drive egui's input and read its output themselves. - let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - // The top-level function, or method of an `impl`, that each line is in. - let (mut function, mut in_impl) = (None, false); - for (n, line) in code.lines().enumerate() { - if line.starts_with("impl") { - (function, in_impl) = (None, true); - } else if line == "}" { - (function, in_impl) = (None, false); - } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { - function = Some(name); - } - if line.trim_start().starts_with("//") { - continue; - } - for name in names.iter().filter(|name| uses(line, name)) { - match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) { - Some(k) => needed[k] = true, - None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)), - } - } + for (file, function, n, name) in found { + match allowed.iter().position(|(f, function_name, _)| *f == file && function.as_deref() == Some(*function_name)) { + Some(k) => needed[k] = true, + None => problems.push(format!("src/{file}:{n}: `{name}` {why}")), } } for ((file, name, _), needed) in allowed.iter().zip(needed) { @@ -317,7 +436,7 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { NOT_PAGE_INPUT there, with why.", "reads no pointer state any more: remove it from NOT_PAGE_INPUT.", ); - // A module that takes the page pointer is a page module, so it is checked too. + // A module that acts on the pages is a page module, so it is checked too. let mut dirs = vec![src.clone()]; while let Some(dir) = dirs.pop() { for entry in std::fs::read_dir(dir).expect("source directory") { @@ -337,8 +456,9 @@ fn page_modules_read_the_pointer_only_through_page_pointer() { let file = path.strip_prefix(&src).expect("under src").components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); let source = std::fs::read_to_string(&path).expect("source file"); - if file != "canvas/page_pointer.rs" && source.contains("PagePointer") && !PAGE_MODULES.contains(&file.as_str()) { - problems.push(format!("src/{file} takes the page pointer: add it to PAGE_MODULES.")); + let marks = PAGE_MODULE_MARKS.iter().filter(|mark| source.contains(**mark)).collect::>(); + if !ROUTER_FILES.contains(&file.as_str()) && !marks.is_empty() && !PAGE_MODULES.contains(&file.as_str()) { + problems.push(format!("src/{file} uses {marks:?}, so it acts on the pages: add it to PAGE_MODULES.")); } } } @@ -362,18 +482,138 @@ fn page_modules_claim_the_cursor_and_never_set_it() { assert!(problems.is_empty(), "{}", problems.join("\n")); } -/// Hit producers are called only by the central routing table, not again by an input handler. -const PAGE_HIT_CALLS: [&str; 8] = [ +/// What decides, keeps or drops the press the pages hold. +const ROUTER_WRITES: [&str; 8] = [ + "router.begin(", + "router.press(", + "router.settle(", + "router.cancel(", + "PageRouter::begin(", + "PageRouter::press(", + "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.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"), +]; + +/// Ways to make a `Hit`. +const HIT_BUILDS: [&str; 2] = ["Hit::new(", "Hit {"]; + +/// Whether `name` is a hit test's: `hit`, or `…_hit`. +fn is_hit_test(name: &str) -> bool { + name == "hit" || name.ends_with("_hit") +} + +/// The hit tests `line` calls: by path, bare or as a method. +fn hit_test_calls(line: &str) -> Vec { + line.match_indices("hit(") + .filter_map(|(at, _)| { + let end = at + "hit".len(); + let start = line[..end].char_indices().rev().find(|(_, c)| !(c.is_alphanumeric() || *c == '_')).map_or(0, |(i, c)| i + c.len_utf8()); + let (before, name) = (&line[..start], &line[start..end]); + (is_hit_test(name) && !before.ends_with("fn ")).then(|| name.to_owned()) + }) + .collect() +} + +/// The functions that call a hit test: (file, function, why). Hit tests are found by name (a +/// function named `hit` or `…_hit`, called by path, bare or as a method). The comments' point test +/// has a list of its own (`COMMENT_POINT_TESTERS`); the others by other names (added content's, +/// Edit text's paragraphs', Redact's text test, the text layer's links) are not listed yet, and +/// most are PR 4b's. `PageHits::hits` is the one: the hits +/// at the press point decide its owner, and those under the pointer give its cursor, its tip, hover +/// painting and a right-click. +const HIT_TESTERS: [(&str, &str, &str); 1] = [( + "canvas/page_input.rs", + "hits", + "`PageHits::hits`: every module's hits at a point, for the pointer's cursor and tip, a right-click and, once, the press's owner", +)]; + +/// The page view's asks for every module's hits at a point (`PageHits::hits`, and `at` and +/// `context_at`, which wrap it). +const PAGE_HITS_ASKS: [&str; 6] = [".hits(", ".at(", ".context_at(", "PageHits::hits(", "PageHits::at(", "PageHits::context_at("]; + +/// The functions that ask the page view's hits at a point: (file, function, why). `PageHits` is +/// private to `page_input.rs`, so this keeps a new function there from asking; a second ask inside +/// a listed function is for review to catch. The method names are matched in every page module, +/// so an `at` of another type there would need them narrowed. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ + ("canvas/page_input.rs", "at", "`PageHits::at`: the hits at a point, as a press there would take them"), + ("canvas/page_input.rs", "context_at", "`PageHits::context_at`: what a right-click at a point is on"), + ( + "canvas/page_input.rs", + "page_tool_input", + "the hits at the press point decide its owner, once per press; those under the pointer before the tools act, once a \ + frame, serve a click's release, a right-click and Fill & Sign's preview", + ), + ("canvas/page_input.rs", "claim_under", "what lies under the pointer after the tools act: its cursor, its tip and hover painting"), + ("canvas/page_input.rs", "right_click", "what a right-click is on"), +]; + +/// The comments' point test, which their hit tests are built on: which comment lies under a point. +const COMMENT_POINT_TESTS: [&str; 2] = [".comment_at(", "PageCx::comment_at("]; + +/// The functions that ask which comment lies under a point: (file, function, why). +const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ + ("tools/comments/input.rs", "hit", "the comments' hit test"), + ("tools/comments/input.rs", "context_hit", "the comments' hit test for a right-click"), + ("tools/comments/paint.rs", "paint_page", "a redaction mark shows its fill under the pointer with every tool, as comments' tips show"), +]; + +/// 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("]; + +/// The functions that read who holds the press: (file, function, why). The page view asks once per +/// page and tells each module what the press is to its page (`PagePress`); the Edit text group, the +/// text layer and link following still ask, until they are told too (PR 4b), and an unused entry +/// fails the test. +const ROUTER_READERS: [(&str, &str, &str); 8] = [ + ( + "canvas/page_input.rs", + "page_tool_input", + "the page view: what the press is to each page (`press_for`); Add text's paragraph and the pictures until 4b", + ), + ("canvas/page_input.rs", "update_selection_on_press", "the text layer selects no text under a press held by what keeps its drag (4b)"), + ("canvas/page_input.rs", "finish_text_pointer_gesture", "a click held by what keeps its drag leaves the text selection alone (4b)"), + ("canvas/page_input.rs", "page_overlays", "a click follows the link that holds its press (4b)"), + ("tools/content_ui.rs", "page_input", "added text and pictures, until they are told (4b)"), + ("tools/edit_text_ui.rs", "page_input", "Edit text's paragraphs, until they are told (4b)"), + ("tools/edit_text_ui/image.rs", "image_input", "Edit text's pictures, until they are told (4b)"), + ("tools/object_ui.rs", "input", "the objects selected together, until they are told (4b)"), +]; + +/// What ignores the press the pages hold until the button is up. +const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; + +/// The one function that ignores a press until the button is up: (file, function, why). +const PRESS_IGNORERS: [(&str, &str, &str); 1] = + [("canvas/page_input.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; + +/// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who +/// may). +const PAGE_HIT_CALLS: [&str; 12] = [ + "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "comments::context_hit(", + "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", + "edit_text_ui::crop_hit(", "object_ui::hit(", "content_ui::hit(", ]; +/// Whether a press handler's `code` acts on the owner the router captured, and calls no hit test +/// of its own to choose one. fn routed_press_handler(code: &str) -> bool { let code = code.lines().filter(|line| !line.trim_start().starts_with("//")).collect::>().join("\n"); code.contains("router.owner(") @@ -383,51 +623,161 @@ fn routed_press_handler(code: &str) -> bool { && !uses(&code, "handle_hit(") } -/// Source-level guard for the Page presses rule, alongside the raw-pointer and cursor guards. -/// Runtime routing, capture lifetime and Escape behavior have separate behavioral tests. +/// 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 +/// point again, or asked another whether it wanted the press, the answers disagreed: a press on +/// one check box released over the next ticked the second, and a drag could be taken over +/// halfway. Only `PageHits::hits` asks the hit tests. Escape is the one way to +/// drop a press and ignore it until the button is up. #[test] -fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { +fn page_modules_act_on_a_press_only_through_the_router() { let mut problems = misuses( - &PAGE_HIT_CALLS, - &[("canvas.rs", "at", "the central PageHits table")], - "repeats a module hit test outside PageHits::at; use the captured router owner", - "no longer collects module hits", + &ROUTER_WRITES, + &ROUTER_WRITERS, + "decides, keeps or drops the press the pages hold. Only the hits at the press point decide its owner, once \ + (src/canvas/page_router.rs), and the page view tells each module what the press is to its page (`PagePress`). Drop a press with \ + `DocView::cancel_pointer_gestures`. A new place that must? Add the function to ROUTER_WRITERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "no longer decides, keeps or drops the press: remove it from ROUTER_WRITERS.", ); problems.extend(misuses( - &["router.press("], - &[("canvas.rs", "page_tool_input", "the central press capture")], - "captures a page press outside the central routing path", - "no longer captures page presses", + &ROUTER_READS, + &ROUTER_READERS, + "reads who holds the press the pages hold. The page view asks the router once per page and tells each module \ + what the press is to its page: take a `PagePress` (src/canvas/page_router.rs) instead. A new place that must? \ + Add the function to ROUTER_READERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer reads who holds the press: remove it from ROUTER_READERS.", + )); + // A router method the lists above don't name yet is the router's all the same: only their + // 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::"); + if router && !known.iter().any(|name| uses(line, name)) { vec!["router".to_owned()] } else { Vec::new() } + }); + problems.extend( + unlisted + .into_iter() + .filter(|(file, function, _, _)| { + !ROUTER_WRITERS.iter().chain(ROUTER_READERS.iter()).any(|(f, name, _)| f == file && function.as_deref() == Some(*name)) + }) + .map(|(file, _, n, _)| { + format!( + "src/{file}:{n}: uses the router by a method ROUTER_WRITES and ROUTER_READS don't name. The page view \ + decides the press and tells each module what it is to its page (`PagePress`): add the method to \ + ROUTER_WRITES or ROUTER_READS in src/canvas/page_pointer/tests.rs, and the function to their lists \ + only if it must." + ) + }), + ); + problems.extend(misuses( + &COMMENT_POINT_TESTS, + &COMMENT_POINT_TESTERS, + "asks which comment lies under a point. The comments' hit tests answer that for a press and a right-click: \ + act on what the page view tells you, rather than testing the point again. A new place that must? Add the \ + function to COMMENT_POINT_TESTERS in src/canvas/page_pointer/tests.rs, with why.", + "no longer asks which comment lies under a point: remove it from COMMENT_POINT_TESTERS.", + )); + problems.extend(misuses( + &PAGE_HITS_ASKS, + &PAGE_HITS_ASKERS, + "asks the page view's hits at a point. The hits at the press point decide its owner once, and those under the \ + pointer are taken once before the tools act and once after: use those (`under`, `PageInput.hover`). A new \ + 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.", + )); + problems.extend(misuses( + &PRESS_IGNORES, + &PRESS_IGNORERS, + "ignores the press the pages hold. Escape cancels a press, in `cancel_on_escape`: drop a gesture with \ + `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/forms_ui.rs", "page_input"), - ("tools/prepare.rs", "page_input"), ("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"), - ("tools/comments/input.rs", "select_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"); - // Forms reuse their hit test for hover text and to require release over the captured - // widget. Neither chooses the press owner. Pin both calls to the current pointer `p`, - // not the press origin, and reject an additional call or a changed argument. - let body = if file == "tools/forms_ui.rs" { - let call = "hit(xf, info, page, form, allowed, p)"; - assert_eq!(body.matches(call).count(), 2, "only the existing hover and release checks are exempt"); - body.replace(call, "hover_or_release_check") - } else { - body - }; 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( + page_module_matches(hit_test_calls), + &HIT_TESTERS, + "calls a hit test. The hits at the press point decide its owner once, in `PageHits::hits`: act on the press when \ + the page view tells you it is yours (`PagePress::Mine`), rather than testing its point again. What lies under \ + the pointer comes from the page view's own hits: hover painting and tips (`PageInput.hover`), a click released \ + over what it was pressed on (`released_over`). A new place that must ask? Add the function to HIT_TESTERS in \ + src/canvas/page_pointer/tests.rs, with why.", + "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)| { + format!( + "src/{file}:{n}: `{name}` makes a hit outside a hit test. A page module answers \"what of mine is \ + here?\" in one function, named `hit` or `…_hit`, so its cursor, its tip and its press agree." + ) + })); assert!(problems.is_empty(), "{}", problems.join("\n")); } +/// A test module in the middle of a file hides only itself from the page guards: the code after +/// it is read too. The guards used to stop at the first test module. +#[test] +fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { + let source = "fn before() {}\n#[cfg(test)]\nmod tests {\n fn inside() {}\n}\n\nfn after() {}\n#[cfg(test)]\nmod more;\nfn last() {}\n"; + let kept: Vec<(usize, &str)> = outside_test_modules(source); + assert_eq!(kept, [(1, "fn before() {}"), (6, ""), (7, "fn after() {}"), (10, "fn last() {}")]); +} + +/// The guard's readers: hit tests by name, the function a line declares, a call rustfmt split, +/// and a hit test imported as another name. +#[test] +fn the_page_guards_read_hit_tests_split_calls_and_aliases() { + assert_eq!(hit_test_calls("let h = crate::forms_ui::hit(xf, p);"), ["hit"]); + assert_eq!(hit_test_calls("cx.comment_hit(p) && picture_hit(p)"), ["comment_hit", "picture_hit"]); + assert!(hit_test_calls("pub(crate) fn hit(xf: &PageXform) -> Option {").is_empty()); + 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); + 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), + [ + (1, None, "impl PageHits<'_> {".to_owned()), + (2, Some("at"), " fn at(&self) {".to_owned()), + (3, Some("at"), " self.hits(view, p, false)".to_owned()), + (5, Some("at"), " }".to_owned()), + (6, None, "}".to_owned()), + (7, Some("after"), "fn after() {".to_owned()), + (9, Some("after"), " let _ = view.router.owner(0);".to_owned()), + (12, None, "}".to_owned()), + ] + ); + let source = + "fn a() {}\n\n#[cfg(any())]\nuse crate::camera_ui::hit as corner_under;\nuse crate::x::{\n hit as x_hit,\n picture_hit as under,\n};\n"; + 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);")); diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 8b91cc865c..e3b88d1a2c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -6,7 +6,8 @@ //! 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. +//! 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. use egui::CursorIcon; @@ -18,7 +19,7 @@ use crate::page_cursor::Layer; pub(crate) enum Owner { /// A comment (or a Fill & Sign mark), by its `/Annots` index, and the part of it. Comment { index: usize, part: CommentPart }, - /// A link, by its index in the document's links. + /// A link, by its index in its page's `/Annots`. Link { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, @@ -35,6 +36,8 @@ pub(crate) enum Owner { /// Added text or a picture placed on the page, by its index among the page's, and a corner of /// the selected one (left, top) or the item itself. Added { index: usize, corner: Option<(bool, bool)> }, + /// Enhance ▸ Camera image: a corner of the quadrilateral (0 left top, then clockwise). + CameraCorner { corner: usize }, } /// The part of a picture a hit is on. @@ -87,7 +90,7 @@ pub(crate) struct Hit { /// Beside the pointer where this hit wins (a link's target). pub tip: Option, /// A drag from here is not this owner's: a click on a highlight selects it, a drag across it - /// selects the text underneath. + /// selects the text underneath; with the Link tool, a drag from a link draws a new one. pub declines_drag: bool, } @@ -106,10 +109,35 @@ pub(crate) fn top(hits: impl IntoIterator, tool: QuickTool) -> Optio }) } +/// What one page has to do with the press the pages hold. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +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 + /// over a panel), and after the press was dropped ([`PageRouter::cancel`]) until the button is + /// up (`Elsewhere` instead when Escape came in the press's own frame; page input skips a + /// cancelled press either way). + Free, + /// The press began on another page, or off the pages (between them, or beside one). + Elsewhere, +} + +impl PagePress { + /// Who holds the press on this page, if anyone. + pub(crate) fn owner(self) -> Option { + match self { + PagePress::Mine(owner) => Some(owner), + PagePress::Free | PagePress::Elsewhere => None, + } + } +} + /// A press the pages hold, and who it belongs to. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Capture { - pub page: usize, pub owner: Owner, pub declines_drag: bool, } @@ -118,18 +146,43 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, + /// A press is held, and the page it began on (`None`: off the pages, between them or beside one). + began: Option>, } 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) { + self.capture = None; + self.began = Some(None); + } + /// A press on `page`: `hits` at the press point decide who it belongs to. A press nobody's /// hit claims is held by nobody (the page's text, or a tool that acts anywhere on the page). + /// 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) { - self.capture = top(hits, tool).map(|h| Capture { page, owner: h.owner, declines_drag: h.declines_drag }); + if self.began != Some(None) { + return; + } + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); + self.began = Some(Some(page)); + } + + /// What `page` has to do with the press the pages hold. + pub(crate) fn press_for(&self, page: usize) -> PagePress { + match (self.began, &self.capture) { + (None, _) => PagePress::Free, + (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), + (Some(Some(p)), None) if p == page => PagePress::Free, + _ => PagePress::Elsewhere, + } } /// Who holds the press on `page`. pub(crate) fn owner(&self, page: usize) -> Option { - self.capture.as_ref().filter(|c| c.page == page).map(|c| c.owner) + self.press_for(page).owner() } /// Whether the press is held by someone who keeps its drag: then the page's text is not @@ -142,12 +195,15 @@ impl PageRouter { pub(crate) fn settle(&mut self, any_down: bool, edges: bool) { if !any_down && !edges { self.capture = None; + self.began = None; } } - /// Drops the press (Escape, a dialog, automation's cancel). + /// 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) { self.capture = None; + self.began = None; } } @@ -172,6 +228,7 @@ mod tests { #[test] fn a_press_is_held_until_the_button_is_up_and_its_release_seen() { 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"); @@ -180,14 +237,49 @@ mod tests { assert!(r.owner(2).is_some(), "the release frame still belongs to the press"); r.settle(false, false); assert_eq!(r.owner(2), None); + r.begin(); r.press(0, [], QuickTool::Select); assert_eq!(r.owner(0), None, "a press nobody claims is held by nobody"); assert!(!r.keeps_drag()); } + /// A page is told whether the press is its owner's, the active tool's, or another page's. + #[test] + fn each_page_is_told_whether_the_press_is_its_own() { + let mut r = PageRouter::default(); + assert_eq!(r.press_for(3), PagePress::Free, "no button down: every page is the tool's"); + r.begin(); + assert_eq!(r.press_for(0), PagePress::Elsewhere, "a press between the pages is no page's"); + r.press(2, [], QuickTool::Select); + assert_eq!((r.press_for(2), r.press_for(1)), (PagePress::Free, PagePress::Elsewhere), "nobody's, on page 2"); + r.begin(); + r.press(1, [hit(Layer::Comment, 4)], QuickTool::Select); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Mine(hit(Layer::Comment, 4).owner), PagePress::Elsewhere)); + r.settle(false, true); + assert_eq!(r.press_for(2), PagePress::Elsewhere, "until its release has been seen"); + r.settle(false, false); + assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); + r.begin(); + r.cancel(); + // Page input skips a press Escape cancelled until the button is up (`press_cancelled`). + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press leaves every page to the tool"); + } + + /// Pages that touch (the gaps hidden) can share a row of pixels: a press there is the first + /// page's to claim it, and the next page's claim is ignored. + #[test] + fn a_press_where_two_pages_touch_is_the_first_pages() { + let mut r = PageRouter::default(); + r.begin(); + r.press(0, [hit(Layer::Comment, 1)], QuickTool::Select); + r.press(1, [hit(Layer::Comment, 2)], QuickTool::Select); + assert_eq!((r.press_for(0), r.press_for(1)), (PagePress::Mine(hit(Layer::Comment, 1).owner), PagePress::Elsewhere)); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { 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()); r.cancel(); diff --git a/crates/ui-egui/src/canvas/signature_drag.rs b/crates/ui-egui/src/canvas/signature_drag.rs index c706b8a113..9f7a071ac7 100644 --- a/crates/ui-egui/src/canvas/signature_drag.rs +++ b/crates/ui-egui/src/canvas/signature_drag.rs @@ -402,8 +402,7 @@ mod tests { let doc = session.get(id).expect("open"); let index = doc.info.annotations.iter().find(|a| a.subtype == "Sound").expect("listed").index; let ctx = egui::Context::default(); - let mut cv = CommentView::default(); - cv.gesture = Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }); + let mut cv = CommentView { gesture: Some(Gesture::Move { page: 0, index, from: pos2(0.0, 0.0) }), ..CommentView::default() }; let mut drag = SignatureDrag::default(); let key = Some((doc.edit_generation(), 0, index)); drag.prepare_move(&ctx, doc, &cv, None, false, 1.0); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 8e5e163ec9..d5b8312d6c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2359,3 +2359,24 @@ fn image_handles_account_for_document_and_view_rotation() { } } } + +/// Cancelling the pages' pointer gestures (Escape, automation's cancel) lets go of a +/// camera corner being dragged: the quadrilateral stays, and no corner follows the pointer. +#[test] +fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { grab: Some(2), ..crate::camera_ui::CameraQuad::inset(0, 300.0, 400.0) }); + v.cancel_pointer_gestures(); + assert_eq!(v.camera.map(|q| q.grab), Some(None)); +} + +/// A camera corner at the page's edge is hit on the page only: a press off the page is no page's, +/// so its grab cursor does not show there. +#[test] +fn a_camera_corner_at_the_page_edge_is_hit_on_the_page_only() { + let mut v = view(1, PageLayout::Single, true); + v.camera = Some(crate::camera_ui::CameraQuad { page: 0, corners: [[0.0, 0.0], [300.0, 0.0], [300.0, 400.0], [0.0, 400.0]], grab: None }); + let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), egui::vec2(300.0, 400.0)), rot: 0, pw: 300.0, ph: 400.0 }; + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(14.0, 24.0)).is_some(), "on the page"); + assert!(crate::camera_ui::hit(&xf, 0, &v, pos2(6.0, 16.0)).is_none(), "just off it"); +} diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 52d4987178..06b13e2745 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -22,15 +22,15 @@ pub enum AreaChoice { Save, } -/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot). -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { +/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`). +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) { let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.marquee = Some((page, o)); @@ -73,7 +73,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p view.marquee_done = Some((page, r, [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)])); } } - if resp.clicked() + if free + && resp.clicked() && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) { let v = xf.screen_to_view(p); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index abaad1ed5c..817d77bb9f 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -7,6 +7,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; use crate::page_cursor::Layer; use crate::page_pointer::PagePointer; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -59,8 +60,29 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3)) } -/// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { +/// The corner of the quadrilateral under `p` on `page`, the nearest within reach: the one hit test +/// of the camera tool, for the cursor and the press. `None` once the page is enhanced, on a page +/// without the quadrilateral, or off the page (a press there is no page's), even where a corner at +/// its edge would reach past it. +pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { + if view.camera_done.is_some() || !xf.rect.contains(p) { + return None; + } + let q = view.camera.filter(|q| q.page == page)?; + let corner = q + .corners + .iter() + .enumerate() + .map(|(i, &c)| (i, screen(xf, c).distance(p))) + .filter(|(_, d)| *d <= GRAB) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i)?; + Some(Hit::new(Owner::CameraCorner { corner }, Layer::Handle, egui::CursorIcon::Grab)) +} + +/// Corner dragging on one page: a corner's own press ([`hit`]) drags it, and a press nobody holds +/// on a page without the quadrilateral moves it there. +pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool, press: PagePress) { let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { @@ -76,23 +98,25 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe if view.camera.is_none() && page == view.current { view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } - if resp.drag_started() || resp.clicked() { - let pressed = origin.filter(|o| xf.rect.contains(*o)); - if pressed.is_some() && view.camera.is_none_or(|q| q.page != page) { - view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); - } - if resp.drag_started() - && let (Some(o), Some(q)) = (pressed, view.camera.as_mut()) - { - q.grab = (0..4) - .map(|i| (i, screen(xf, q.corners[i]).distance(o))) - .filter(|(_, d)| *d <= GRAB) - .min_by(|a, b| a.1.total_cmp(&b.1)) - .map(|(i, _)| i); - } + // A press nobody holds on a page without the quadrilateral moves it there: a drag from where it + // 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()) + && 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() + && let PagePress::Mine(Owner::CameraCorner { corner }) = press + && let Some(q) = view.camera.as_mut().filter(|q| q.page == page) + { + q.grab = Some(corner); } let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return }; - let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB)); + // Over a corner, its hit claims the grab cursor. if let Some(i) = q.grab { view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); if let Some(p) = pointer { @@ -101,11 +125,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe *c = [x.clamp(0.0, xf.pw), y.clamp(0.0, xf.ph)]; } } - if resp.drag_stopped() { + if resp.drag_stopped() || !ptr.primary_down { q.grab = None; } - } else if near { - view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab); } } diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index cb8bafe642..8d118ac3c9 100644 --- a/crates/ui-egui/src/tools/comments.rs +++ b/crates/ui-egui/src/tools/comments.rs @@ -37,7 +37,7 @@ pub(crate) use geometry::{corner_ratio, page_rotation, resized}; pub use geometry::{drawn_text_box_rect, text_box_rect}; #[cfg(test)] use input::hover_tip; -pub(crate) use input::{describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input}; pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid}; #[cfg(test)] use menu::{RECENT_CUSTOM, same_colour}; diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 051cf09dba..ee67b92bb0 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -20,10 +20,10 @@ pub(super) fn reshapable(a: &Annotation) -> bool { && !a.intent.as_deref().is_some_and(|i| i.ends_with("Dimension")) } -/// Whether `tool` picks up a line's end and corner handles: the Select and Fill & Sign tools. +/// Whether `tool` reaches a line's end and corner handles: the Select and Fill & Sign tools. /// The drawing tools draw from where they are pressed, so a new line starts at the end of the /// one just drawn rather than dragging it. -pub(super) fn grabs_points(tool: QuickTool) -> bool { +pub(super) fn reaches_points(tool: QuickTool) -> bool { !matches!(tool, QuickTool::Comment(t) if t.draws() || t.clicks_points()) } diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a7ef6c92e1..ac3c2a6878 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,61 +1,69 @@ use super::geometry::{ - CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, grabs_points, handle_pos, is_markup, point_handle, reshapable, reshaped, resizable, + CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable, resized, snapped_caret, }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; use crate::page_cursor::{Layer, Subject}; -use crate::page_router::{CommentPart, Hit, Owner}; +use crate::page_router::{CommentPart, Hit, Owner, PagePress}; -/// Comment input on one page, before text selection runs. 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<'_>, view: &mut DocView) -> bool { +/// Comment input on one page, before text selection runs. `press` is what this page has to do with +/// 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 +/// 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); let page_rect = cx.xf.rect; - let pressed_here = origin.is_some_and(|o| page_rect.contains(o)); + let free = press == PagePress::Free; + let pressed_here = free && origin.is_some_and(|o| page_rect.contains(o)); let over_page = pointer.is_some_and(|p| page_rect.contains(p)); - let fill_grab = fill_grabs(cx, view); - let draw_grab = draw_grabs(cx, view); + let owned = match press { + PagePress::Mine(Owner::Comment { index, part }) => Some((index, part)), + _ => None, + }; let caret_text = if cx.tool == QuickTool::Comment(CommentTool::Caret) { view.page_text(cx.page) } else { None }; let cv = &mut view.comments; if cv.gesture.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { cv.gesture = None; - cv.cancelled_drag = true; return true; } - if cv.cancelled_drag { - return true; - } - if resp.secondary_clicked() - && let Some(p) = pointer.filter(|p| page_rect.contains(*p)) - { - cv.context_at = Some((cx.page, cx.to_user(p))); - cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); - } - let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; + let moving = moving_on(view, cx.page); + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { - QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), + // The selected shape's handles, and Fill & Sign's marks, take their own presses. + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), + // Neither a press nor a double-click or a key another tool took this frame. + QuickTool::Comment(_) if taken => false, QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), - QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin), - QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, press, pointer, origin), _ => false, } } +/// A comment (a Fill & Sign mark included) is being moved, resized or reshaped on `page`: it keeps +/// the page's frames until its gesture ends. +pub(crate) fn moving_on(view: &DocView, page: usize) -> bool { + matches!(view.comments.gesture, Some(Gesture::Move { page: p, .. } | Gesture::Resize { page: p, .. } | Gesture::Reshape { page: p, .. }) if p == page) +} + /// What of the comments lies under `p`, for the cursor, the tip and the press: the one hit test /// of this module. With the Select tool, the selected comment's point handles, then its resize /// handles, then the topmost comment. With a Fill & Sign tool, the same over Fill & Sign's marks /// only, as Acrobat picks them up: a press anywhere else places the tool's mark. With a shape /// tool (rectangle, oval, line, polygon…), only the selected comment's handles, so the shape just /// drawn can be resized straight away (#2338); a press anywhere else, the shape itself included, -/// draws. `None` with other tools, off the page, or with comments hidden. -pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { +/// draws. `None` with other tools, off the page, or with comments hidden. `on_link`: a link lies +/// under `p` ([`crate::link_ui::hit`]). +pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } @@ -73,7 +81,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { }; let bodies = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); if let Some(a) = view.comments.selected.filter(|(page, _)| *page == cx.page).and_then(|(_, i)| cx.get(i)).filter(|a| pick(a) && cx.allowed) { - if grabs_points(cx.tool) + if reaches_points(cx.tool) && reshapable(a) && let Some(point) = point_handle(cx, a, p) { @@ -91,48 +99,45 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { return Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Handle(x, y) }, Layer::Handle, icon)); } } - let a = cx.hit(p).filter(|a| bodies && pick(a))?; + let a = cx.comment_at(p).filter(|a| bodies && pick(a))?; // A click on a highlight over a link follows the link, so the link's hand and tip show there. - if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link(cx, p) { + if cx.tool == QuickTool::Select && a.subtype == "Highlight" && on_link { return None; } let markup = is_markup(&a.subtype); let icon = if cx.allowed && !markup { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand }; - // What a drag moves whole (a note, a shape, a signature, a Fill & Sign mark) is placed on the - // page, over a form field; text markup lies on the text, under one. - let layer = if a.fill_sign || !markup { Layer::Placed } else { Layer::Comment }; // Text markup does not move: a click selects it, a drag across it selects the text. - Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) + Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), icon) }) } -/// Whether the comments take the press on this page (or, with no button down, would take one -/// under the pointer), so the tools beneath leave it: a live move, resize or reshape, or a press -/// held by a comment that keeps its drag. -fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - if matches!(view.comments.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.page) - { - return true; +/// The layer a comment's body lies on: what a drag moves whole (a note, a shape, a signature, a +/// Fill & Sign mark) is placed on the page, over a form field; text markup lies on the text, under +/// one. +fn layer(a: &Annotation) -> Layer { + if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment } +} + +/// The comment a right-click at `p` is on: the topmost, with every tool, as a comment's menu shows +/// whatever tool is active. A highlight over a link is the highlight's here (a left click on it +/// follows the link, [`hit`]). `None` off the page or with comments hidden. +pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { + if cx.hidden || !cx.xf.rect.contains(p) { + return None; } - if cx.ptr.any_down { - return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); - } - cx.ptr.pos.and_then(|p| hit(cx, view, p)).is_some_and(|h| !h.declines_drag) + let a = cx.comment_at(p)?; + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } -/// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select -/// tool ([`hit`]), so the tool does not place a mark there. -pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view) +/// A right-click at `p` on this page: the page's menu is anchored there (Delete page, Paste, Copy +/// Image, a note added here). +pub(crate) fn anchor(cx: &PageCx<'_>, p: Pos2, view: &mut DocView) { + view.comments.context_at = Some((cx.page, cx.to_user(p))); } -/// With a shape tool, the selected comment's handles are picked up ([`hit`]). -fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Comment(_)) && grabs(cx, view) -} - -/// Whether `p` is on one of this page's links. -fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool { - cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)) +/// A right-click on comment `index` of this page ([`context_hit`], when it wins there) selects it, +/// and one on anything else selects none. +pub(crate) fn context(cx: &PageCx<'_>, index: Option, view: &mut DocView) { + view.comments.selected = index.map(|i| (cx.page, i)); } /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat @@ -170,11 +175,21 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option Some(if body.is_empty() { who } else { format!("{who}\n{body}") }) } -/// The Select tool: select, move, resize, edit, delete, on the comment the press was on (its -/// owner, from [`hit`]). The cursor comes from [`hit`] too. -fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView, pointer: Option, origin: Option) -> bool { - let owner = match view.router.owner(cx.page) { - Some(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), +/// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the +/// part of it (`press`, from [`hit`]). The cursor comes from [`hit`] too. A click on this page on +/// anything else (a link, a field, added text, the page) leaves comments unselected; one that +/// began off the pages is no page's. +fn select_input( + ui: &egui::Ui, + resp: &egui::Response, + cx: &PageCx<'_>, + view: &mut DocView, + press: PagePress, + pointer: Option, + origin: Option, +) -> bool { + let owner = match press { + PagePress::Mine(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), _ => None, }; let cv = &mut view.comments; @@ -252,15 +267,16 @@ fn select_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, view: &mu // (egui clears the press origin on release, so clicks are located by the pointer.) let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p)); if resp.clicked() && on_page { - // A click elsewhere (a link beneath text highlighting included: it follows the link) - // leaves comments unselected. + // A click elsewhere (a link beneath text highlighting included: it follows the link; a + // form field; added text) leaves comments unselected. match owner { Some((a, _)) => { cv.selected = Some((cx.page, a.index)); cv.reveal = true; consumed = true; } - None => cv.selected = None, + None if press != PagePress::Elsewhere => cv.selected = None, + None => {} } } if resp.double_clicked() @@ -316,13 +332,21 @@ struct Gestures<'a> { pointer: Option, origin: Option, page_rect: Rect, + /// The press began on this page, and nobody holds it: the tool's. pressed_here: bool, over_page: bool, + /// Nobody holds the press, and it began on this page, or no button is down. + free: bool, } impl Gestures<'_> { + /// The tool's own click: one nobody holds. + fn clicked(&self) -> bool { + self.free && self.resp.clicked() + } + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { - let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -370,7 +394,7 @@ impl Gestures<'_> { // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a // single PDF point is visible and remains editable like any other drawing. if matches!(tool, CommentTool::Ink | CommentTool::FreehandHighlight) - && resp.clicked() + && self.clicked() && over_page && let Some(p) = pointer && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) @@ -402,7 +426,7 @@ impl Gestures<'_> { cv.undo_point(); return true; } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -482,7 +506,7 @@ impl Gestures<'_> { } fn attach(&self, view: &mut DocView) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -490,7 +514,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -501,7 +525,7 @@ impl Gestures<'_> { } fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { - let Self { resp, cx, pointer, over_page, .. } = *self; + let Self { cx, pointer, over_page, .. } = *self; let cv = &mut view.comments; if !cx.allowed { return false; @@ -509,7 +533,7 @@ impl Gestures<'_> { if over_page { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text); } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -563,7 +587,7 @@ impl Gestures<'_> { } } } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -577,9 +601,6 @@ impl Gestures<'_> { /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { - if !ctx.input(|i| i.pointer.any_down()) { - view.comments.cancelled_drag = false; - } if ctx.egui_wants_keyboard_input() { return; } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 59b5ca773a..8faae3d02e 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,18 +1,30 @@ -use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; -use super::state::{Gesture, PageCx, SETTLE_SECS}; +use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable}; +use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS}; use super::{ Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, Stroke, color32, vec2, }; +use crate::page_router::{CommentPart, Hit, Owner}; -/// Paint comment selection, hover and gesture previews on a page. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { +/// The comment outlined under the pointer: the one a press there would take (`hover`, the top hit), +/// with the Select tool or, over its own marks, Fill & Sign. Not the selected comment, nor while a +/// comment is moved, resized or drawn, nor one under a form field or added text that would take +/// the press. +pub(super) fn hover_outline<'c>(cx: &'c PageCx<'_>, cv: &CommentView, hover: Option<&Hit>) -> Option<&'c Annotation> { + let Some(Owner::Comment { index, part: CommentPart::Body }) = hover.map(|h| h.owner) else { return None }; + let tool_outlines = matches!(cx.tool, QuickTool::Select | QuickTool::Fill(_)); + (cv.gesture.is_none() && tool_outlines && cv.selected != Some((cx.page, index))).then(|| cx.get(index)).flatten() +} + +/// Paint comment selection, hover and gesture previews on a page. `hover` is the top hit under the +/// pointer. +pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView, hover: Option<&Hit>) { let cv = &view.comments; let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) + && let Some(a) = pointer.and_then(|p| cx.comment_at(p)) && a.subtype == "Redact" { let ([r, g, b], opacity) = cx.redact_mark; @@ -21,11 +33,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> painter.rect_filled(rect, CornerRadius::ZERO, fill); } } - if cv.gesture.is_none() - && let Some(a) = pointer.and_then(|p| cx.hit(p)) - && (cx.tool == QuickTool::Select || matches!(cx.tool, QuickTool::Fill(_)) && a.fill_sign) - && cv.selected != Some((cx.page, a.index)) - { + if let Some(a) = hover_outline(cx, cv, hover) { for r in cx.screen_rects(a) { painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside); } @@ -45,7 +53,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> return paint_gesture(painter, cx, view); } let shift = ui.input(|i| i.modifiers.shift); - if cx.allowed && grabs_points(cx.tool) && reshapable(a) { + if cx.allowed && reaches_points(cx.tool) && reshapable(a) { return paint_points(painter, cx, view, a, pointer, shift); } let r = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, view.pending_edit.as_ref()).expand(2.0); diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 6cac912210..9c876d950f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -133,8 +133,6 @@ pub struct CommentView { /// Drawings just released: shown from their points until the page raster that includes the /// committed annotation arrives, so the stroke never vanishes in between (#1858). pub(crate) settling: Vec, - /// Escape ends egui's drag too; ignore its synthetic release until the mouse is up. - pub(super) cancelled_drag: bool, pub composer: Option, /// Reply being typed under the selected card. pub reply: String, @@ -386,7 +384,7 @@ impl PageCx<'_> { /// The topmost comment under `p`: the one drawn last, in `/Annots` order (inspection lists /// comments by position, not in paint order). - pub(super) fn hit(&self, p: Pos2) -> Option<&Annotation> { + pub(super) fn comment_at(&self, p: Pos2) -> Option<&Annotation> { self.comments().filter(|a| self.screen_rects(a).any(|r| r.expand(3.0).contains(p))).max_by_key(|a| a.index) } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index 6110e8a6fc..0f9edd6c22 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -499,3 +499,39 @@ fn text_boxes_fit_their_text() { assert_eq!(long[2], 300.0); assert!(long[3] - long[1] > 50.0); } + +/// The hover outline goes round the comment a press under the pointer would take (the top hit +/// there): not a highlight under a form field, which takes the press. +#[test] +fn the_hover_outline_goes_round_what_a_press_would_take() { + use super::paint::hover_outline; + use crate::page_cursor::Layer; + use crate::page_router::{CommentPart, Hit, Owner}; + let page = PageInfo { width: 200.0, height: 200.0, label: String::new(), crop: [0.0, 0.0, 200.0, 200.0], rotation: 0 }; + let xf = PageXform { rect: Rect::from_min_size(Pos2::ZERO, vec2(200.0, 200.0)), rot: 0, pw: 200.0, ph: 200.0 }; + let highlight = Annotation { rect: [50.0, 50.0, 150.0, 150.0], ..annotation("Highlight", None, false) }; + let info = DocInfo { pages: vec![page], annotations: vec![highlight], ..Default::default() }; + let prefs = CommentPrefs::default(); + // The pointer is over the highlight. + let ptr = PagePointer { pos: Some(pos2(100.0, 100.0)), ..PagePointer::default() }; + let cx = PageCx { + page: 0, + xf: &xf, + ptr: &ptr, + info: &info, + tool: QuickTool::Select, + prefs: &prefs, + allowed: true, + hidden: false, + redact_mark: ([0.0; 3], 1.0), + }; + let on = |owner, layer| Hit::new(owner, layer, egui::CursorIcon::PointingHand); + let comment = on(Owner::Comment { index: 0, part: CommentPart::Body }, Layer::Comment); + let field = on(Owner::Field { field: 0, widget: 0 }, Layer::Field); + let cv = CommentView::default(); + assert!(hover_outline(&cx, &cv, Some(&comment)).is_some(), "the highlight a press there selects"); + assert!(hover_outline(&cx, &cv, Some(&field)).is_none(), "not under a field that takes the press"); + assert!(hover_outline(&cx, &cv, None).is_none(), "nor where nothing would take it"); + let selected = CommentView { selected: Some((0, 0)), ..CommentView::default() }; + assert!(hover_outline(&cx, &selected, Some(&comment)).is_none(), "the selected comment has its own frame"); +} diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 681564c146..f520a91f86 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -23,7 +23,7 @@ mod layout; 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, handle_hit as picture_handle_hit, image_context_menu, image_input, + 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, }; #[cfg(test)] 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 600f3d8801..d3c76f94e2 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -541,6 +541,14 @@ 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 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. diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 597e139c9e..ae5fc47371 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -455,7 +455,7 @@ pub(crate) struct PageInput<'a> { } /// Preview and clicks with a Fill & Sign tool on one page. -pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &mut DocView) -> Option { +pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bool, view: &mut DocView) -> Option { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let PageInput { tool, text_size, text_color, signature, initials, preview, author, date_text } = input; let pointer = ptr.within(xf.rect)?; @@ -475,7 +475,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m } else { view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair }); } - if !resp.clicked() { + // The tool's own click: one nobody holds. + if !(free && resp.clicked()) { return None; } // Typed text starts a little above the click, as displayed. diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 7232b1169b..1d31b81fbc 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -15,7 +15,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; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -182,19 +182,30 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField Some(Hit::new(Owner::Field { field, widget }, Layer::Field, icon)) } -/// Presses and clicks on form widgets of one page, on the field the press was on ([`hit`]). -/// `true` while a field holds the press. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool { - let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; - // The field's tooltip (`/TU`) under the pointer, as Acrobat shows it on hover (#3854). ISO - // 32000-2 Table 226 makes it the name the user interface identifies the field by. - if let Some(p) = ptr.within(xf.rect) - && let Some(Owner::Field { field, .. }) = hit(xf, info, page, form, allowed, p).map(|h| h.owner) +/// The tooltip (`/TU`) of the field under the pointer, as Acrobat shows it on hover (#3854): only +/// where the field is what a press would take (`hover`, the top hit there). ISO 32000-2 Table 226 +/// makes it the name the user interface identifies the field by. +pub(crate) fn tooltip(resp: &egui::Response, form: &[FormField], hover: Option<&Hit>) { + if let Some(Owner::Field { field, .. }) = hover.map(|h| h.owner) && let Some(tip) = form.get(field).and_then(|f| f.tooltip.as_deref()).filter(|t| !t.trim().is_empty()) { resp.clone().on_hover_text_at_pointer(tip); } - let Some(Owner::Field { field, widget: wi }) = view.router.owner(page) else { return false }; +} + +/// Presses and clicks on form widgets of one page, on the field the press is on (`press`, from +/// [`hit`]). `released_over`: the press's click came up over the widget it began on. `true` while a +/// field holds the press. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + allowed: bool, + press: PagePress, + released_over: bool, + view: &mut DocView, +) -> bool { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; + let PagePress::Mine(Owner::Field { field, widget: wi }) = press else { return false }; let Some((f, w)) = form.get(field).and_then(|f| f.widgets.get(wi).map(|w| (f, w))) else { return false }; let usable = usable(f, w, allowed); // A text field opens on the press rather than the click, so a drag that starts on it @@ -210,8 +221,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b } // A click counts released over the widget its press was on, as a button's does: one that slips // off it first does nothing (a check box's overlay closes on it too). - if !resp.clicked() || !ptr.pos.and_then(|p| hit(xf, info, page, form, allowed, p)).is_some_and(|h| h.owner == Owner::Field { field, widget: wi }) - { + if !resp.clicked() || !released_over { return true; } if !allowed { @@ -489,7 +499,8 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page, the pages' `pointer` over it. +/// Paint focus and hover frames on a page: the hover frame round the widget a press under the +/// pointer would take (`hover`, the top hit there). pub(crate) fn paint_page( painter: &egui::Painter, xf: &PageXform, @@ -497,9 +508,13 @@ pub(crate) fn paint_page( info: &DocInfo, form: &[FormField], view: &DocView, - pointer: Option, + hover: Option<&Hit>, ) { - for f in form { + let hovered = match hover.map(|h| h.owner) { + Some(Owner::Field { field, widget }) => Some((field, widget)), + _ => None, + }; + for (fi, f) in form.iter().enumerate() { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); @@ -508,7 +523,7 @@ pub(crate) fn paint_page( } if view.forms.unbaked.contains(&(f.name.clone(), page)) { paint_unbaked_widget(painter, xf, f, w, r); - } else if fillable(f) && pointer.is_some_and(|p| r.contains(p)) { + } else if fillable(f) && hovered == Some((fi, wi)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index a0840e1b89..63db81b9f3 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -4,14 +4,17 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; -use pdfcraft_render::DocInfo; +use pdfcraft_render::{DocInfo, LinkTarget}; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::page_cursor::Layer; +use crate::page_router::{Hit, Owner}; use crate::theme::{self, Tokens}; -use crate::{PdfCraftApp, widgets}; +use crate::{PdfCraftApp, QuickTool, widgets}; const LINK_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); +/// How far around a link (screen points) the Link tool finds it, so a thin one can be picked. +const LINK_TOOL_MARGIN: f32 = 2.0; #[derive(Clone, Debug, Default, PartialEq)] pub struct LinkView { @@ -46,7 +49,7 @@ pub struct LinkDraft { pub style: LinkStyle, } -pub(crate) fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { +fn screen_rect(xf: &PageXform, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { xf.user_rect(info, page, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]) } @@ -56,8 +59,44 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { [u[0] as f64, u[1] as f64] } -/// Link tool input on one page. Returns `true` when the gesture belongs to the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut DocView) -> bool { +/// The link under `p` on `page`: the one hit test of links, for the hand, the tip and the press. +/// Of stacked links, the topmost (the last in `/Annots`, drawn on top). Only the Select tool +/// follows links and the Link tool edits them (`Layer::reached_by`). The Link tool finds every link +/// the document has (`links`, the engine's), with a margin around each so a thin one can be +/// picked, and a drag from one draws a new link. The other tools find the links the renderer can +/// follow, which have a target (`info.links`). The tip is the link's target. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem], tool: QuickTool, p: Pos2) -> Option { + // A press off the page is no page's, so nothing there is a link's, the margin included. + if !xf.rect.contains(p) { + return None; + } + let (index, link) = if tool == QuickTool::Link { + let index = links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index; + (index, info.links.iter().find(|l| l.page == page && l.index == index)) + } else { + let link = info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + (link.index, Some(link)) + }; + let tip = link.map(|l| target_label(info, &l.target)); + let declines_drag = tool == QuickTool::Link; + Some(Hit { tip, declines_drag, ..Hit::new(Owner::Link { index }, Layer::Link, egui::CursorIcon::PointingHand) }) +} + +/// What a link's tip says it goes to. +fn target_label(info: &DocInfo, target: &LinkTarget) -> String { + match target { + LinkTarget::Page(n, _) => crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]), + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + } +} + +/// 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. +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; let lv = &mut view.links; @@ -75,36 +114,39 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut } return true; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p)); - match hit { - Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand), - None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair), - } - if resp.secondary_clicked() - && let Some(l) = hit + // A drag that nobody holds, or from a link (whose hit lets the drag go), draws a new link from + // where its press began on this page. egui may see the drag only once the pointer is past the + // page's edge. + if resp.drag_started() + && (free || owner.is_some()) + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) { - lv.selected = Some((page, l.index)); - lv.context = Some((page, l.index)); + lv.drag = Some((page, origin)); return true; } - if resp.clicked() || resp.double_clicked() { + if !pointer.is_some_and(|p| xf.rect.contains(p)) { + return false; + } + // Over a link, its hit claims the hand. + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if let Some(index) = context { + lv.selected = Some((page, index)); + lv.context = Some((page, index)); + return true; + } + // The tool's own click: on a link, or one nobody holds. + if (resp.clicked() || resp.double_clicked()) && (free || owner.is_some()) { let now = ui.input(|i| i.time); // A second click on the same link soon after the first is a double-click. - let double = resp.double_clicked() || hit.is_some_and(|l| lv.last_click.is_some_and(|(t, p, k)| p == page && k == l.index && now - t < 0.5)); - lv.last_click = hit.map(|l| (now, page, l.index)); - lv.selected = hit.map(|l| (page, l.index)); - if double && let Some(l) = hit { - lv.open_existing = Some((page, l.index)); + let double = resp.double_clicked() || owner.is_some_and(|k| lv.last_click.is_some_and(|(t, p, l)| p == page && l == k && now - t < 0.5)); + lv.last_click = owner.map(|k| (now, page, k)); + lv.selected = owner.map(|k| (page, k)); + if double && let Some(k) = owner { + lv.open_existing = Some((page, k)); lv.last_click = None; } return true; } - if resp.drag_started() { - let origin = ptr.origin.unwrap_or(p); - lv.drag = Some((page, origin)); - return true; - } resp.is_pointer_button_down_on() } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..2d6336cee6 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -202,9 +202,23 @@ fn snap_to_content( None } +/// The Measure tools' own clicks this frame: those of a press nobody holds. +#[derive(Clone, Copy)] +pub(crate) struct Clicks { + pub single: bool, + pub double: bool, +} + +impl Clicks { + /// `resp`'s clicks, when the press is the tool's (`free`). + pub(crate) fn of(resp: &egui::Response, free: bool) -> Self { + Clicks { single: free && resp.clicked(), double: free && resp.double_clicked() } + } +} + pub(crate) fn page_input( ui: &egui::Ui, - resp: &egui::Response, + clicks: Clicks, doc: &Document, view: &mut DocView, cx: &crate::comments::PageCx<'_>, @@ -254,7 +268,7 @@ pub(crate) fn page_input( point = measure::snap::constrain(*last, point); } hover = Some(point); - if resp.clicked() { + if clicks.single { if state.page != Some(page) { state.cancel(); state.page = Some(page); @@ -284,7 +298,7 @@ pub(crate) fn page_input( } } if state.page == Some(page) && !state.points.is_empty() { - if (resp.double_clicked() || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { + if (clicks.double || !typing && ui.input(|i| i.key_pressed(egui::Key::Enter))) && matches!(tool, Tool::Perimeter | Tool::Area) { finish(state, &mut view.pending_edit, doc, page, tool, author, units); } if !typing && ui.input(|i| i.key_pressed(egui::Key::Backspace)) { diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index cc4b81441e..602358d1e0 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -13,7 +13,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageToolContext, PageXform}; use crate::comments; use crate::page_cursor::Layer; -use crate::page_router::{Hit, Owner}; +use crate::page_router::{Hit, Owner, PagePress}; use crate::theme; use crate::widgets; @@ -285,8 +285,17 @@ pub(crate) fn hit( Some(Hit::new(Owner::PrepareField { field, widget }, Layer::Placed, egui::CursorIcon::Move)) } -/// Pointer input on one page while preparing a form. -pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Option, allowed: bool, view: &mut DocView) -> Outcome { +/// Pointer input on one page while preparing a form: `press` says whether the press is a field's +/// or a corner's ([`hit`]), nobody's (a field tool draws, the Select tool draws a selection +/// rectangle), or another page's. +pub(crate) fn page_input( + cx: PageToolContext<'_>, + form: &[FormField], + tool: Option, + allowed: bool, + press: PagePress, + view: &mut DocView, +) -> Outcome { let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; let pointer = ptr.pos; @@ -369,71 +378,30 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; - if !allowed { - return out; - } - if let Some(tool) = tool { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.drag_started() || resp.clicked() { - let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); - prep.grab = Some((page, Grab::Draw(origin), origin)); - out.consumed = true; - if resp.clicked() { - // A click without a drag: finish at once. - prep.grab = None; - if tool == FieldTool::TextTable { - out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO)))); - out.placed = true; - return out; - } - let at = to_user(xf, info, page, p); - let (w, h) = tool.default_size(); - let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); - prep.select_added = Some(form.len()); - view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None }); - out.placed = true; - } - } - return out; - } - - // Right-click a field: select it (the context menu acts on it). - if resp.secondary_clicked() - && let Some((f, wi)) = form.iter().find_map(|f| { - f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i)) - }) - { - let key = (f.name.clone(), wi); - if let Some(i) = prep.also.iter().position(|k| *k == key) { - // Part of the selection: it becomes the anchor the others line up with. - prep.also.remove(i); - if let Some(old) = prep.selected.replace(key) { - prep.also.push(old); - } - } else if prep.selected.as_ref() != Some(&key) { - prep.selected = Some(key); - prep.also.clear(); - } - } - // The Select tool: what the press was on ([`hit`]), a corner handle or a field; elsewhere a - // drag draws a selection rectangle. A press held by something drawn over the fields (a - // comment) is not Prepare's. - let owner = view.router.owner(page); - let held = matches!(owner, Some(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); - let hit = match owner { - Some(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), + // 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. + let free = press == PagePress::Free; + let held = matches!(press, PagePress::Mine(Owner::PrepareField { .. } | Owner::PrepareCorner { .. })); + let hit = match press { + PagePress::Mine(Owner::PrepareField { field, widget }) => form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || owner.is_none()) { - let origin = ptr.origin.unwrap_or(p); - if let Some(Owner::PrepareCorner { left, top }) = owner { + // A drag starts from where its press began on this page: egui may see it only once the pointer + // is past the page's edge. + if allowed + && resp.drag_started() + && (held || free) + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o)) + { + out.consumed = true; + if tool.is_some() { + prep.grab = Some((page, Grab::Draw(origin), origin)); + } else if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { prep.grab = Some((page, Grab::Corner(left, top), origin)); - out.consumed = true; } else if let Some((f, wi)) = hit { prep.selected = Some((f.name.clone(), wi)); prep.grab = Some((page, Grab::Move, origin)); - out.consumed = true; } else { prep.marquee_before = Some(prep.selected.iter().chain(prep.also.iter()).cloned().collect()); if !ui.input(|i| i.modifiers.shift || i.modifiers.command) { @@ -441,7 +409,29 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti prep.also.clear(); } prep.grab = Some((page, Grab::Select(origin), origin)); + } + return out; + } + let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return out }; + if !allowed { + return out; + } + if let Some(tool) = tool { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // A click without a drag places the field at once. + if free && resp.clicked() { out.consumed = true; + if tool == FieldTool::TextTable { + out.table = Some((page, table_rect(xf, Rect::from_min_size(p, egui::Vec2::ZERO)))); + out.placed = true; + return out; + } + let at = to_user(xf, info, page, p); + let (w, h) = tool.default_size(); + let join = prep.selected.as_ref().and_then(|(n, _)| form.iter().find(|f| &f.name == n)); + prep.select_added = Some(form.len()); + view.pending_edit = Some(Edit::AddField { page, rect: [at[0], at[1] - h, at[0] + w, at[1]], kind: tool.new_field(join), name: None }); + out.placed = true; } return out; } @@ -450,7 +440,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out.properties = true; return out; } - if resp.clicked() && (held || owner.is_none()) { + if resp.clicked() && (held || free) { let extend = ui.input(|i| i.modifiers.shift || i.modifiers.command); match hit { // Shift/⌘-click adds or removes a field from the selection. @@ -469,6 +459,28 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti out } +/// A right-click on widget `widget` of field `field` (the top hit there, [`hit`]): the field is +/// selected, so the menu acts on it. Part of a selection of several, it becomes the anchor the +/// others line up with. Not while a field is dragged or a rectangle drawn: the release acts on +/// the selection the drag began with. +pub(crate) fn context(form: &[FormField], field: usize, widget: usize, view: &mut DocView) { + let Some(f) = form.get(field) else { return }; + let key = (f.name.clone(), widget); + let prep = &mut view.prepare; + if prep.grab.is_some() || prep.ignore_release { + return; + } + if let Some(i) = prep.also.iter().position(|k| *k == key) { + prep.also.remove(i); + if let Some(old) = prep.selected.replace(key) { + prep.also.push(old); + } + } else if prep.selected.as_ref() != Some(&key) { + prep.selected = Some(key); + prep.also.clear(); + } +} + /// Align, Center, Distribute and Set Fields to Same Size (Prepare a form, several fields /// selected). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index e55dd8c5c3..345e7e5d77 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -233,9 +233,9 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Redact tool input on a page. Presses on text are left to text selection (the selection is -/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when -/// the gesture is a box. -pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool { +/// marked when it ends, see [`after_text`]); presses nobody holds (`free`) elsewhere draw a box. +/// Returns `true` when the gesture is a box. +pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, free: bool, view: &mut DocView) -> bool { let PageToolContext { resp, xf, ptr, page, info, .. } = cx; let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag @@ -252,17 +252,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo } return true; } - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false }; - if !over_text(p) { - view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + // The tool's own press, of the primary button: one nobody holds, which began on this page off + // its text. egui may see the drag only once the pointer is past the page's edge. + if resp.drag_started_by(egui::PointerButton::Primary) + && free + && let Some(origin) = ptr.origin.filter(|o| xf.rect.contains(*o) && !over_text(*o)) + { + view.redact_drag = Some((page, origin)); + return true; } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. - if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() { - let origin = ptr.origin.unwrap_or(p); - if !over_text(origin) { - view.redact_drag = Some((page, origin)); - return true; - } + if pointer.is_some_and(|p| xf.rect.contains(p) && !over_text(p)) { + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } false } diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index 8d357650c2..ada8655cd8 100644 --- a/crates/ui-egui/src/tools/sign_ui.rs +++ b/crates/ui-egui/src/tools/sign_ui.rs @@ -8,10 +8,9 @@ use egui::{Align, Color32, CornerRadius, Layout, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::sign::{self, Appearance, AppearanceLabels, Certificate, DigitalId, Name, PrivateKey}; use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatus}; -use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; +use crate::canvas::{DocView, PageToolContext, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; use crate::page_cursor::Layer; -use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -162,23 +161,17 @@ pub struct SignView { pub field: Option, } -/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)). -pub(crate) fn page_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &pdfcraft_render::DocInfo, - view: &mut DocView, -) { +/// Drag a signature rectangle on one page (Digitally sign, Certify (visible signature)), from a +/// press nobody holds (`free`). +pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, view: &mut DocView) { + let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx; let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); } - // The tool's own press, of the primary button: nothing drawn over the page here holds it. + // The tool's own press, of the primary button: one nobody holds. if resp.drag_started_by(egui::PointerButton::Primary) - && view.router.owner(page).is_none() + && free && let Some(o) = origin.filter(|o| xf.rect.contains(*o)) { view.sign.drag = Some((page, o)); diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 9cddb31d23..484c7eb938 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -6,6 +6,8 @@ use std::sync::Arc; use egui::{Align, Layout}; use serde::{Deserialize, Serialize}; +use crate::canvas::{DocView, PageToolContext}; +use crate::page_cursor::Layer; use crate::theme::{self, Tokens}; use crate::{Dialog, PdfCraftApp, QuickTool, widgets}; @@ -41,6 +43,54 @@ mod b64 { } } +/// The Stamp tool on one page: a click that nobody holds (`free`) places stamp `kind` centred on +/// it, upright as the page is shown. `true` once one is placed. +pub(crate) fn place(cx: PageToolContext<'_>, kind: pdfcraft_engine::StampKind, by_line: &str, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let a = page_info.view_to_user((f64::from(vx) - w / 2.0) as f32, (f64::from(vy) - h / 2.0) as f32); + let b = page_info.view_to_user((f64::from(vx) + w / 2.0) as f32, (f64::from(vy) + h / 2.0) as f32); + let rect = [f64::from(a[0].min(b[0])), f64::from(a[1].min(b[1])), f64::from(a[0].max(b[0])), f64::from(a[1].max(b[1]))]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.to_owned()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.to_owned(), + })); + true +} + +/// A custom stamp on one page: a click that nobody holds (`free`) places `stamp` centred on it, at +/// its natural size (the engine sizes it). `true` once one is placed. +pub(crate) fn place_custom(cx: PageToolContext<'_>, stamp: &CustomStamp, author: &str, free: bool, view: &mut DocView) -> bool { + let PageToolContext { resp, xf, ptr, page, info, .. } = cx; + let (Some(p), Some(page_info)) = (ptr.within(xf.rect), info.pages.get(page)) else { return false }; + view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); + if !(free && resp.clicked()) { + return false; + } + let (vx, vy) = xf.screen_to_view(p); + let u = page_info.view_to_user(vx, vy); + let (x, y) = (f64::from(u[0]), f64::from(u[1])); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page, + rect: [x, y, x, y], + name: stamp.name.clone(), + file: pdfcraft_engine::MarkFile { name: stamp.file.clone(), bytes: stamp.data.clone(), page: stamp.page }, + author: author.to_owned(), + }); + true +} + /// The Create Custom Stamp dialog: the chosen file, its category and name. #[derive(Clone, Debug, Default, PartialEq)] pub struct StampDraft { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 889caf4c1c..50801d903f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use egui_kittest::kittest::Queryable; use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::fill_sign::FillTool; -use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, QuickTool, UnlockPrompt}; use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; @@ -957,3 +957,864 @@ fn escape_cancels_a_crop_rectangle() { let s = h.state(); assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "no page box was set"); } + +// --------------------------------------------------------------------------------------------- +// One link test: what the Select tool follows, the Link tool edits and a right-click opens is +// the same link. + +/// A 300×400 page with two web links overlapping at x 150..200, y 300..350: `first` first in +/// `/Annots`, then `second` drawn over it. +fn stacked_links_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 200 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [150 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/second) >> >>", + ]) +} + +/// A 300×400 page with a link that does nothing (no action) at x 10..40, y 300..350, then a web +/// link at x 50..250 beside it: second in `/Annots`, and the first the renderer can follow. +fn dead_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [10 300 40 350] /Border [0 0 0] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// [`link_pdf`], with a rectangle comment drawn over the middle of its link. +fn note_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Square /Rect [100 310 200 340] /C [1 0 0] /F 4 /T (Ana) /Contents (A note) >>", + ]) +} + +fn link_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Link; + h.run_steps(4); +} + +/// A right-click at `p`: press and release in frames of their own. +fn right_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(3); +} + +fn pending_url(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().pending_link.as_ref().map(|l| l.url.clone()) +} + +/// Of two stacked links the Link tool selects the one drawn on top, the one the Select tool +/// follows. It used to select the one underneath. +#[test] +fn the_link_tool_selects_the_topmost_of_stacked_links() { + let mut h = open(stacked_links_pdf(), "150", false); + let both = at(&h, 175.0, 325.0); + mouse_click(&mut h, both); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/second"), "the Select tool follows the link on top"); + h.state_mut().pending_link = None; + link_tool(&mut h); + mouse_click(&mut h, both); + assert_eq!(h.state().views[0].links.selected, Some((0, 1)), "the Link tool selects it too"); +} + +/// The Link tool edits every link, one that does nothing included. The Select tool, which +/// follows links, shows no hand over it, and follows the link beside it by its own `/Annots` +/// place, not by its place among the links the renderer can follow. +#[test] +fn the_link_tool_selects_a_link_with_no_action() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + assert_ne!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand, "the Select tool has nothing to follow there"); + let web = at(&h, 150.0, 325.0); + mouse_click(&mut h, web); + assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the web link is followed"); + h.state_mut().pending_link = None; + link_tool(&mut h); + assert_eq!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand); + mouse_click(&mut h, dead); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the Link tool selects the link that does nothing"); +} + +/// With the Link tool a drag that starts on a link draws a new link: the press is the link's, its +/// drag is not. +#[test] +fn a_drag_with_the_link_tool_from_a_link_draws_a_new_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_drag(&mut h, on_link, egui::vec2(80.0, 120.0)); + let s = h.state(); + assert_eq!(s.dialog, Some(Dialog::LinkProps)); + assert_eq!(s.link_draft.as_ref().map(|d| d.index), Some(None), "Link Properties opens for a new link"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: just beside the +/// link it opens the page's menu. The Link tool's margin around links used to reach there. +#[test] +fn a_right_click_beside_a_link_with_the_select_tool_opens_no_link_menu() { + let mut h = open(link_pdf(), "150", false); + // The link ends at x 250. + let beside = at(&h, 250.0, 325.0) + egui::vec2(1.5, 0.0); + right_click(&mut h, beside); + assert_eq!(h.state().views[0].links.context, None, "no link under the right-click"); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + let on_link = at(&h, 245.0, 325.0); + right_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.context, Some((0, 0)), "on the link, its menu"); +} + +/// A right-click goes to what a press there would go to: on a note over a link, the note. It used +/// to open the link's menu. +#[test] +fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + right_click(&mut h, on_note); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's"); +} + +/// [`link_pdf`], with a highlight drawn over the middle of its link. +fn highlight_over_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on a highlight over a link follows the link, but a right-click there is the +/// highlight's: it is selected and its menu opens. The link's menu used to open, with the +/// highlight selected underneath. +#[test] +fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() { + let mut h = open(highlight_over_link_pdf(), "150", false); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link"); + assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + mouse_click(&mut h, on_both); + assert!(h.state().pending_link.is_some(), "a click there still follows the link"); +} + +/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's: +/// with the Rectangle tool, a right-click on a comment selects it. +#[test] +fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let on_comment = at(&h, 100.0, 305.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); + assert!(h.query_all_by_label("Reply").next().is_some(), "and its menu is open"); +} + +/// The check box `agree` of [`checkbox_pdf`], with a rectangle comment at x 150..250, y 100..150. +fn comment_and_checkbox_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + "<< /Type /Annot /Subtype /Square /Rect [150 100 250 150] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A click on anything but the selected comment leaves it unselected, a form field included, as +/// a click on a link or on the page does. A field's click used to keep it selected. +#[test] +fn a_click_on_a_form_field_deselects_the_selected_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + let comment = at(&h, 200.0, 125.0); + mouse_click(&mut h, comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the comment is selected"); + let check_box = at(&h, 110.0, 210.0); + mouse_click(&mut h, check_box); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected"); + assert_eq!(agree(&h).len(), 1, "and ticks the box"); +} + +/// The camera tool's quadrilateral, corner by corner, in page view points. +fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> { + h.state().views[0].camera.map(|q| q.corners) +} + +/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its +/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also +/// leaves the camera tool before the pages see it; this holds whatever the order of the keys. +#[test] +fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { + let mut h = open(text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + // The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page. + let corner = at(&h, 15.0, 380.0); + let start = camera_corners(&h); + h.hover_at(corner); + h.run_steps(1); + h.drag_at(corner); + h.run_steps(1); + h.hover_at(corner + egui::vec2(30.0, 30.0)); + h.run_steps(2); + assert_ne!(camera_corners(&h), start, "the corner follows its drag"); + h.key_press(egui::Key::Escape); + h.run_steps(1); + let end = corner + egui::vec2(60.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(2); + let after = camera_corners(&h); + h.hover_at(corner + egui::vec2(120.0, 90.0)); + h.run_steps(3); + assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled"); + assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held"); +} + +/// A 300×400 page with a text field `name` (tooltip "Your name") at x 50..250, y 200..220, and a +/// rectangle comment drawn over its right end, x 150..260, y 190..230. +fn commented_text_field_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [50 200 250 220] /P 3 0 R /F 4 /DA (/Helv 12 Tf 0 g) >>", + "<< /Type /Annot /Subtype /Square /Rect [150 190 260 230] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// Rests the pointer at `p` long enough for a tooltip. +fn rest_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + for _ in 0..40 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + } +} + +/// A field's tooltip (`/TU`) shows only where a press would take the field: not under a comment +/// drawn over it. It used to show over the whole field. +#[test] +fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { + let mut h = open(commented_text_field_pdf(), "150", false); + let under_comment = at(&h, 200.0, 210.0); + rest_at(&mut h, under_comment); + assert!(h.query_by_label("Your name").is_none(), "no field tooltip where the comment takes the press"); + let on_field = at(&h, 100.0, 210.0); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press"); +} + +/// With the Link tool a right-click on a link is the link's, as its press there is, though a note +/// lies over it: the Link tool edits links and reaches no comment. +#[test] +fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() { + let mut h = open(note_over_link_pdf(), "150", false); + link_tool(&mut h); + let on_both = at(&h, 150.0, 325.0); + right_click(&mut h, on_both); + let v = &h.state().views[0]; + assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note"); +} + +/// With Add text a right-click leaves comments alone, as before: Add text takes the page's +/// right-clicks. +#[test] +fn a_right_click_with_add_text_selects_no_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::AddText; + h.run_steps(4); + // Above the line of text the comment covers, where no paragraph is. + let on_comment = at(&h, 100.0, 317.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected"); +} + +/// With the Select tool a right-click finds a link only where a click follows it: a link that +/// does nothing has no menu. The Link tool's own test used to give it one. +#[test] +fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() { + let mut h = open(dead_link_pdf(), "150", false); + let dead = at(&h, 25.0, 325.0); + right_click(&mut h, dead); + assert_eq!(h.state().views[0].links.context, None); + assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items"); +} + +/// [`link_pdf`], with a check box over the middle of its link. +fn field_over_link_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>", + ]) +} + +/// A right-click on a form field over a link is the field's, as a press there is: no link menu. +#[test] +fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { + let mut h = open(field_over_link_pdf(), "150", false); + let on_field = at(&h, 150.0, 325.0); + right_click(&mut h, on_field); + assert_eq!(h.state().views[0].links.context, None, "the field is on top"); + assert!(h.query_by_label("Remove all links").is_none()); +} + +/// While the palette is open, a field beside it still shows its tooltip, as before: the palette +/// keeps the page's own tips away, not egui's tooltip of a field. +#[test] +fn a_fields_tooltip_shows_beside_the_open_palette() { + let mut h = open(commented_text_field_pdf(), "150", false); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown"); + let on_field = at(&h, 60.0, 210.0); + assert!(!palette.expand(20.0).contains(on_field), "the field is beside the palette {palette:?}"); + rest_at(&mut h, on_field); + assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows"); +} + +/// Two blank 300×400 pages. +fn two_pages_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Page /Parent 2 0 R >>", + ]) +} + +/// Sets a tool up (`tool`), presses above page 1's bottom edge and drags onto page 2 in one frame, +/// so egui sees the drag only once the pointer is on page 2, and returns the app after the release. +fn drag_seen_only_past_page_ones_edge(tool: impl FnOnce(&mut PdfCraftApp)) -> Harness<'static, PdfCraftApp> { + let mut h = open(two_pages_pdf(), "100", false); + tool(h.state_mut()); + 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 press = egui::pos2(one.min.x + 50.0, one.max.y - 30.0); + let release = egui::pos2(one.min.x + 150.0, two.min.y + 20.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); + h +} + +// A drag seen only past its page's edge still draws on the page its press began on, as zoom, crop +// and Sign area do. With these three it used to draw nothing anywhere. + +#[test] +fn a_redaction_box_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Redact); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.info.annotations.iter().any(|a| a.subtype == "Redact" && a.page == 0), "a redaction mark on page 1"); +} + +#[test] +fn a_link_rectangle_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Link); + assert_eq!(h.state().link_draft.as_ref().map(|d| (d.page, d.index)), Some((0, None)), "a new link on page 1"); +} + +#[test] +fn a_field_dragged_off_its_page_is_drawn_on_it() { + let h = drag_seen_only_past_page_ones_edge(|app| assert!(app.execute("form.add.text"), "Prepare a form, with the text field tool")); + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1"); +} + +/// With the Link tool a right-click on a link is the link's and leaves comments as they are, as +/// before: the selected comment stays selected. +#[test] +fn a_right_click_with_the_link_tool_on_a_link_keeps_the_selected_comment() { + let mut h = open(note_over_link_pdf(), "150", false); + let on_note = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_note); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note is selected"); + link_tool(&mut h); + // On the link, away from the note. + let on_link = at(&h, 70.0, 325.0); + right_click(&mut h, on_link); + let v = &h.state().views[0]; + assert_eq!((v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 1))), "the link's menu; the note stays selected"); +} + +/// A 300×400 page with a rectangle comment round the camera tool's first corner (view 15, 20). +fn comment_at_camera_corner_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [5 365 45 395] /C [1 0 0] /F 4 /T (Ana) >>", + ]) +} + +/// A camera corner has no menu: a right-click on a comment under it opens the comment's, as +/// before. +#[test] +fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() { + let mut h = open(comment_at_camera_corner_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EnhanceCamera; + h.run_steps(4); + let corner = at(&h, 15.0, 380.0); + right_click(&mut h, corner); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); +} + +/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350. +fn edge_link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [0 300 100 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// The Link tool's margin round a link stops at the page's edge: beside the page, where a press +/// does nothing, no hand promises one (as before the margin moved into the one link test). +#[test] +fn the_link_tools_hand_stops_at_the_pages_edge() { + let mut h = open(edge_link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 10.0, 325.0); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "on the link"); + let beside = at(&h, 0.0, 325.0) - egui::vec2(1.0, 0.0); + 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. +#[test] +fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_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.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page"); +} + +/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not +/// selected. +#[test] +fn a_right_click_with_a_field_tool_selects_no_comment() { + let mut h = open(comment_and_checkbox_pdf(), "150", false); + assert!(h.state_mut().execute("form.add.text")); + h.run_steps(4); + let on_comment = at(&h, 200.0, 125.0); + right_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, None); +} + +/// With the Link tool a click that began off the page (in the gutter beside it) is no page's: the +/// selected link stays selected. It used to unselect it. +#[test] +fn a_click_that_began_off_the_page_keeps_the_selected_link() { + let mut h = open(link_pdf(), "150", false); + link_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + mouse_click(&mut h, on_link); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the link is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_link.y), egui::pos2(left + 3.0, on_link.y)); + assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A right-click while a comment is moved leaves the moved comment selected: the move ends on +/// it. The right-click used to select the comment under it mid-drag. +#[test] +fn a_right_click_during_a_comment_move_keeps_the_moved_comment_selected() { + let mut h = open(stacked_comments_pdf(), "150", false); + // "Bottom note" alone, then "Top note" alone. + let (bottom, top) = (at(&h, 60.0, 160.0), at(&h, 190.0, 290.0)); + h.hover_at(bottom); + h.run_steps(1); + h.drag_at(bottom); + h.run_steps(1); + h.hover_at(bottom + egui::vec2(0.0, 20.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: top, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + let end = bottom + egui::vec2(0.0, 40.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the moved comment stays selected"); +} + +/// A click on added text leaves the selected comment unselected, as a click on a form field does +/// (ruling 1). It used to keep it selected. +#[test] +fn a_click_on_added_text_deselects_the_selected_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + // The Edit panel open, with the Select tool: added text is picked up. + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Select; + h.run_steps(3); + let on_comment = at(&h, 150.0, 315.0); + mouse_click(&mut h, on_comment); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected"); +} + +/// A comment gesture left over with no button down (a release the pages never saw) does not keep +/// a right-click from selecting the comment under it. +#[test] +fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom }); + let top = at(&h, 190.0, 290.0); + right_click(&mut h, top); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected"); +} + +/// 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] +fn a_click_that_began_off_the_page_keeps_the_selected_comment() { + let mut h = open(stacked_comments_pdf(), "150", false); + let bottom = at(&h, 60.0, 160.0); + mouse_click(&mut h, bottom); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the note is selected"); + let left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, bottom.y), egui::pos2(left + 3.0, bottom.y)); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a click that began off the page leaves it selected"); +} + +/// A touch's long press on a comment selects it for its menu, as a right-click does: egui reports +/// it as a right-click with the finger still down, which holds no drag. +#[test] +fn a_long_press_on_a_comment_selects_it_for_its_menu() { + let mut h = open(stacked_comments_pdf(), "150", false); + let top = at(&h, 190.0, 290.0); + let touch = |phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [touch(egui::TouchPhase::Start, top), egui::Event::PointerMoved(top), button(top, true)]); + // Held still, past egui's long-press time. + h.run_steps(6); + assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the finger is selected"); + one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); + h.run_steps(2); +} + +/// With the page menu anchored on page 1, presses on page 2 and holds the button, presses Escape +/// first with `escape`, right-clicks on page 2, and gives the page the menu is anchored on. +fn menu_page_after_a_right_click_with_the_button_held(escape: bool) -> Option { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let press = two.min + egui::vec2(50.0, 30.0); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + h.hover_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(1); + if escape { + h.key_press(egui::Key::Escape); + h.run_steps(1); + } + let menu = press + egui::vec2(100.0, 60.0); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE }); + } + h.run_steps(1); + let page = h.state().views[0].comments.context_at.map(|(page, _)| page); + h.drop_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(2); + page +} + +/// A right-click while the primary button is held anchors the page's menu where it is (Delete page, +/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page +/// right-clicked before. +#[test] +fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { + assert_eq!(menu_page_after_a_right_click_with_the_button_held(false), Some(1), "the menu is page 2's"); +} + +/// So does a right-click while a press Escape cancelled is held, though the cancelled press takes +/// nothing else. +#[test] +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. +#[test] +fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + h.state_mut().quick_tool = QuickTool::EditText; + h.run_steps(4); + h.state_mut().views[0].comments.selected = Some((0, 0)); + let on_added = at(&h, 90.0, 202.0); + right_click(&mut h, on_added); + assert_eq!(h.state().views[0].comments.selected, None); +} + +/// Two pages with their gaps hidden, after `tool` set the tool up, and a point on the row of pixels +/// where they touch, which lies on both. +fn touching_pages(tool: impl FnOnce(&mut PdfCraftApp)) -> (Harness<'static, PdfCraftApp>, egui::Pos2) { + let mut h = open(two_pages_pdf(), "100", false); + h.state_mut().views[0].show_page_gaps = false; + tool(h.state_mut()); + 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 seam = egui::pos2(one.min.x + 100.0, one.max.y); + assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}"); + (h, seam) +} + +/// The pages the document's stamps are on. +fn stamp_pages(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").map(|a| a.page).collect() +} + +/// Where pages touch (the gaps hidden), a press on the row they share is the first page's: a stamp +/// clicked there lands on page 1. The second page used to take the press over. +#[test] +fn a_stamp_clicked_where_two_pages_touch_lands_on_the_first() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + mouse_click(&mut h, seam); + assert_eq!(stamp_pages(&h), vec![0]); +} + +/// A click pressed on one page and released on the next (the gaps hidden, within egui's click +/// distance) places nothing: the press is the first page's, and the pointer is off it. `main` placed +/// the stamp on the second page. +#[test] +fn a_stamp_click_pressed_on_one_page_and_released_on_the_next_places_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved)); + slipping_click(&mut h, seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 2.0)); + assert_eq!(stamp_pages(&h), Vec::::new()); +} + +/// Where pages touch (the gaps hidden), a right-click on the row they share is the first page's: +/// the menu is anchored there. Both pages used to take it, and the second's anchor stood. +#[test] +fn a_right_click_where_two_pages_touch_is_the_first_pages() { + let (mut h, seam) = touching_pages(|_| {}); + right_click(&mut h, seam); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0)); +} + +/// With Prepare open, a click that began off the page (in the gutter beside it) is no page's: the +/// selected field stays selected, as the Link tool's and comments' selections do. +#[test] +fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() { + 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 left = page(&h).min.x; + slipping_click(&mut h, egui::pos2(left - 2.0, on_box.y), egui::pos2(left + 3.0, on_box.y)); + assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected"); +} + +/// 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] +fn a_note_tool_click_on_added_text_starts_no_note() { + let mut h = open(comment_on_text_pdf(), "150", false); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText { + page: 0, + text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() }, + })); + assert!(h.state_mut().execute("edit.text")); + h.run_steps(3); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Note); + h.run_steps(3); + let on_added = at(&h, 90.0, 202.0); + mouse_click(&mut h, on_added); + 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. +#[test] +fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { + let mut h = open(comment_on_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check); + h.run_steps(4); + let marks = |h: &Harness<'_, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.len() + }; + let before = marks(&h); + let blank = at(&h, 150.0, 100.0); + 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); + assert_eq!(marks(&h), before, "no mark is placed"); +} + +/// A right-click between the pages anchors no page's menu, so Delete page and Paste act on the +/// current page. The menu used to keep the anchor of the last right-click on a page. +#[test] +fn a_right_click_between_the_pages_anchors_no_pages_menu() { + let mut h = open(two_pages_pdf(), "100", false); + h.run_steps(2); + h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0])); + 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 gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.context_at, None); +} + +/// Two 300×400 pages, a rectangle comment on the first. +fn two_pages_and_a_comment_pdf() -> Vec { + pdf(&[ + CATALOG, + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /F 4 /T (Reviewer) >>", + ]) +} + +/// A right-click between the pages selects no comment, as one on a page's empty part does not, so +/// the menu that opens is the current page's. The selected comment's menu used to open there. +#[test] +fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments() { + let mut h = open(two_pages_and_a_comment_pdf(), "100", false); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected"); + 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 gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0); + assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}"); + right_click(&mut h, gap); + assert_eq!(h.state().views[0].comments.selected, None); + assert!(h.query_by_label("Rotate view counterclockwise").is_some(), "the page menu opens"); + 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. +#[test] +fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() { + let (mut h, seam) = touching_pages(|app| { + app.system_clipboard = false; + app.quick_tool = QuickTool::EditText; + }); + let clip = || pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) }; + h.state_mut().clipboard_override = Some(clip()); + assert!(h.state_mut().paste_image_at(1, [100.0, 400.0]), "a picture across page 2's top edge"); + h.state_mut().clipboard_override = Some(clip()); + h.state_mut().views[0].image_selection = None; + 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(); + h.run_steps(3); + let pasted = { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().page_images(1) + }; + 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:?}"); +} + +/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it +/// does. The click was located by its press's origin, which egui has cleared by the release frame, +/// so it did nothing (on `main` too). +#[test] +fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { + 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.map(|q| q.page), Some(0), "it starts on the current page"); + let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0)); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1)); +} + +/// A camera click is located where it came up, not where the pointer went after it in the same +/// frame: pressed on page 2 and released on page 1 (the gaps hidden), it moves nothing, though the +/// pointer is back on page 2 by the frame's end. The pointer's last position used to move the +/// quadrilateral to page 2. +#[test] +fn a_camera_click_released_off_its_page_moves_nothing() { + let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::EnhanceCamera); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page"); + let (press, release, back) = (seam + egui::vec2(0.0, 2.0), seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 40.0)); + h.hover_at(press); + h.run_steps(1); + h.drag_at(press); + h.run_steps(1); + let up = egui::Event::PointerButton { pos: release, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [up, egui::Event::PointerMoved(back)]); + h.run_steps(2); + assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0)); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..749eaac69a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1562,3 +1562,85 @@ fn button_export_properties_apply_cancel_and_undo() { assert_eq!(f.export_of(0), Some(original.as_str())); } } + +/// The screen rectangle of widget 0 of field `name` on the first view. +fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +/// A right-click while a field is dragged leaves the dragged field selected, so the drag moves it +/// and not the field right-clicked. The right-click used to select the other field, and the release +/// moved that one. +#[test] +fn a_right_click_during_a_field_drag_moves_the_dragged_field() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + let (name, city) = (field_rect(&h, "name"), field_rect(&h, "city")); + let a = name.center(); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(a + egui::vec2(0.0, 30.0)); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: city.center(), button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }); + } + h.run_steps(1); + let end = a + egui::vec2(0.0, 60.0); + h.hover_at(end); + h.run_steps(1); + h.drop_at(end); + h.run_steps(3); + assert_eq!(field_rect(&h, "city"), city, "the field right-clicked stays where it was"); + assert_ne!(field_rect(&h, "name"), name, "the dragged field moved"); +} + +/// With Prepare open, a drag with a shape tool on the page's empty part draws Prepare's selection +/// rectangle and no comment, as before: Prepare takes the press nobody holds. It used to draw both. +#[test] +fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.square")); + h.run_steps(3); + assert!(h.state().is_preparing(), "picking the tool leaves Prepare open"); + // Below the fields. + let (from, to) = (at(&h, 40.0, 260.0), at(&h, 200.0, 360.0)); + drag(&mut h, from, to); + let s = h.state(); + let squares = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Square").count(); + assert_eq!(squares, 0, "no rectangle under Prepare's selection rectangle"); +} + +/// With Prepare open, a double-click on a field is the field's (it opens its properties) and leaves +/// a polygon being clicked out unfinished, as before. It used to finish the polygon too. +#[test] +fn with_prepare_open_a_double_click_on_a_field_leaves_the_polygon_unfinished() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(3); + assert!(h.state_mut().execute("comment.polygon")); + h.run_steps(3); + // Three points below the fields. + for (x, y) in [(40.0, 260.0), (200.0, 260.0), (120.0, 360.0)] { + let p = at(&h, x, y); + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); + } + let on_field = field_rect(&h, "name").center(); + crate::support::input::double_click(&mut h, on_field); + let s = h.state(); + let polygons = s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Polygon").count(); + assert_eq!(polygons, 0, "the polygon is not finished"); + assert!(matches!(s.views[0].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Draw { .. })), "it is still being clicked out"); +}