From 12e17a1f648e31fbcc831536d144639b0279f503 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:52:29 +0100 Subject: [PATCH 01/28] refactor(ui): crop mode's hit is a hit test of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crop mode owns every press on its page, the controls beside its frame included (0595d4d39). `PageHits::at` made that hit itself. A `Hit` comes from a module's hit test, named `hit` or `…_hit`, so the cursor, the tip and the press agree, and the Page presses guard holds the page modules to it. The picture module's `crop_hit` makes it now, and `PageHits::at` asks it first, as before. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 12 +++--------- crates/ui-egui/src/tools/edit_text_ui.rs | 3 ++- crates/ui-egui/src/tools/edit_text_ui/image.rs | 8 ++++++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56d2674213..b281d044a5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1804,18 +1804,12 @@ impl PageHits<'_> { 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. + // Crop mode owns every press on its page (`edit_text_ui::crop_hit`). if tool == QuickTool::EditText && can_modify - && let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some()) + && let Some(crop) = crate::edit_text_ui::crop_hit(view, page) { - 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, - )]; + return vec![crop]; } let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 19a165c152..9100c6d603 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -23,7 +23,8 @@ 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, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, + ImageCx, Matrix, body_hit as picture_hit, crop_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, + user_to_screen, }; #[cfg(test)] use image::{rotate_knob, turn_degrees, turn_degrees_delta}; diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 281f7de80e..53d3fb1483 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -519,6 +519,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. From 0cbd61d611568ba176b148369e9efac2632689cd Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:03:36 +0100 Subject: [PATCH 02/28] test(ui): one source-scan guard for the Page presses rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5426 took the page pointer router from #5300 at 11bc8c56, before #5300 added its guard. The guard on main checks seven named press handlers, eight hit tests called by path and one `router.press(` caller. Planted inside `#[cfg(any())]`, it caught one of five violations: a second `link_hit` in `page_tool_input`, a bare `hit(` in comments' `page_after_text`, `view.router.cancel()` and `press_cancelled = true` in redact_ui all passed. This brings in #5300's rules beside main's press-handler check, in one test, `page_modules_act_on_a_press_only_through_the_router` (from aef301d82): - only `page_tool_input`, `draw_pages` and `cancel_pointer_gestures` press, settle or cancel the router; - only `cancel_on_escape` ignores a press until the button is up; - every `hit` and `…_hit` call, bare or by path, is in a function allowed to make one, with why; - a `Hit` is made only in a hit test; - an allowlist entry that is no longer used fails the test. It caught all five planted violations, and a stale ROUTER_WRITERS entry. CLAUDE.md's "Page presses" line names the test. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 146 +++++++++++++++--- 2 files changed, 123 insertions(+), 25 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5e9328f8d3..7b1d7f5023 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 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_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/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2cbde2b995..e92d4c01df 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -263,13 +263,11 @@ 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 == '_')) } -/// 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 { +/// 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). +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 problems = Vec::new(); - let mut needed = vec![false; allowed.len()]; + 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"); // Tests drive egui's input and read its output themselves. @@ -287,14 +285,36 @@ fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &s 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 name in find(line) { + found.push((file, function.map(str::to_owned), n + 1, name)); } } } + 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 { + 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, 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) { if !needed { problems.push(format!("src/{file}: fn {name} {unused}")); @@ -362,18 +382,68 @@ 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; 3] = ["router.press(", "router.settle(", "router.cancel("]; + +/// The functions that decide, keep or drop the press the pages hold: (file, function, why). +const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ + ("canvas.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, a dialog, 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 or bare. A method, after its `.`, is left out: +/// `PageCx::hit` finds the comment under a point, not a `Hit`. +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('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + }) + .collect() +} + +/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the +/// press point; the others ask where the pointer is. +const HIT_TESTERS: [(&str, &str, &str); 4] = [ + ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), + ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), +]; + +/// 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.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; 9] = [ "prepare::hit(", "forms_ui::hit(", "comments::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,21 +453,30 @@ 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::at` asks the hit tests at the press point. 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): a page module reads `view.router.owner(page)`. 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", + &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 [ @@ -425,6 +504,25 @@ fn page_modules_take_presses_from_the_router_without_repeating_hit_tests() { 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::at`: act on the press when \ + `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + is (a tooltip, a click released over what it was pressed on)? 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.", + )); + 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")); } From 71d222cacf118eeb343c608d93668f618bff8fbb Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 11:01:16 +0100 Subject: [PATCH 03/28] refactor(ui): move page input out of canvas.rs into canvas/page_input.rs What the tools do with the pointer on one page now lives in its own file: `PageInput`, `PageToolContext` and `page_tool_input`; `PageHits`, `edit_inventory`, `link_hit` and `cancel_on_escape`; the text layer, `text_layer` to `finish_text_pointer_gesture`; and `page_overlays`, which follows links and picks images. `cached_edit_images` stays in canvas.rs, where painting and the menus use it too. No change in behaviour. canvas.rs goes from 4,073 to 3,265 lines. The guard's page modules gain `canvas/page_input.rs`, and its allowlist entries move with the functions. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 810 +---------------- crates/ui-egui/src/canvas/page_input.rs | 815 ++++++++++++++++++ .../ui-egui/src/canvas/page_pointer/tests.rs | 12 +- 3 files changed, 826 insertions(+), 811 deletions(-) create mode 100644 crates/ui-egui/src/canvas/page_input.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b281d044a5..289edf811d 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; @@ -63,6 +64,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; @@ -1444,308 +1447,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(); @@ -1756,472 +1457,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 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(); - [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; @@ -2283,43 +1518,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, 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..32500d7154 --- /dev/null +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -0,0 +1,815 @@ +//! 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, LinkTarget, 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::{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, +} + +/// 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. +pub(super) 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) +} + +/// 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 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(); + [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. +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); + 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); + } + } + } +} + +/// 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. +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/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e92d4c01df..248b627298 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,8 +150,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; 36] = [ "canvas.rs", + "canvas/page_input.rs", "canvas/area.rs", "canvas/bars.rs", "canvas/cache.rs", @@ -387,7 +388,7 @@ const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.can /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ - ("canvas.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), + ("canvas/page_input.rs", "page_tool_input", "the hits at the press point decide its owner, once per press"), ("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, a dialog, automation's cancel"), ]; @@ -416,8 +417,8 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), - ("canvas.rs", "page_overlays", "a link's click counts only released over the link its press was on"), + ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; @@ -426,7 +427,8 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ 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.rs", "cancel_on_escape", "Escape abandons the gesture the press drives, whichever tool's")]; +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). From 2b256ab0c79eff8c97b54efdf143685faab4b65c Mon Sep 17 00:00:00 2001 From: alehel Date: Sat, 31 Oct 2026 08:31:01 +0100 Subject: [PATCH 04/28] refactor(ui): one link test for the Select tool, the Link tool and right-clicks Three link tests disagreed. The Select tool's hand and click (`link_hit`) took the topmost link the renderer lists, with no margin, by its place in that list. The Link tool's selection and Select's right-click took the bottom one of every link the engine lists, 2 px around each, by `/Annots` index. Comments had a fourth (`on_link`). Now `link_ui::hit` is the one test, and `Owner::Link` carries the `/Annots` index: - the Link tool finds every link the engine lists, 2 px around each, and takes the topmost; a drag from a link still draws a new one (the hit declines the drag); - every other tool finds the links the renderer can follow, with no margin (so Select shows no hand over a link with no action, as before), and takes the topmost; - following a link looks its target up by that index; - `comments::hit` is told whether a link is under the point. The Link tool is told the link its press is on, and the one a right-click is on, instead of testing the pointer itself. A right-click goes to the top hit under the pointer, so with the Select tool a right-click on a note over a link selects the note and opens its menu, and one just beside a link no longer opens the link's. Behaviour changes (the user's rulings, 2026-10-31): the Link tool takes the topmost of stacked links; Select's right-click has no margin; a right-click on a note over a link is the note's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 54 ++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 3 +- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 15 +- crates/ui-egui/src/tools/link_ui.rs | 66 ++++++--- crates/ui-egui/tests/it/pointer.rs | 134 +++++++++++++++++- 6 files changed, 215 insertions(+), 59 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 32500d7154..5ca60f8684 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,11 +2,12 @@ //! 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, LinkTarget, PageText}; +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, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -88,6 +89,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut field_tool, prepare_allowed, added, + doc_links, lines: &lines, images: &images, clip: ui.clip_rect(), @@ -96,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } + // A right-click goes to what a press there would go to: the top hit under the pointer. + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -270,14 +274,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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); + let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, link_owner, link_context, 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)) + && let Some(index) = link_context { - view.links.context = Some((i, l.index)); + view.links.context = Some((i, 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 @@ -292,6 +295,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageInput { consumed, area_tool } } +/// The link `owner` is, by its `/Annots` index. +fn link_of(owner: Option) -> Option { + match owner { + Some(Owner::Link { index }) => Some(index), + _ => None, + } +} + /// Edit text's paragraphs and pictures on page `i`, kept while the document is unchanged. fn edit_inventory( view: &mut DocView, @@ -328,6 +339,8 @@ struct PageHits<'a> { /// 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). @@ -355,6 +368,7 @@ impl PageHits<'_> { field_tool, prepare_allowed, added, + doc_links, lines, images, clip, @@ -400,10 +414,9 @@ impl PageHits<'_> { // 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() + let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); + let comment = comments::hit(pcx, view, p, link.is_some()); + [link, comment, pictures, paragraphs, added, objects, grips, fields].into_iter().flatten().collect() } } @@ -694,9 +707,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ 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) + && let Some(owner @ Owner::Link { index }) = view.router.owner(i) + && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && let Some(l) = info.links.iter().find(|l| l.page == i && l.index == index) { frame.out.clicked_link = Some(l.target.clone()); } @@ -777,21 +790,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf } } -/// 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. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 248b627298..4f9398be85 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -432,7 +432,8 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 9] = [ +const PAGE_HIT_CALLS: [&str; 10] = [ + "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 8b91cc865c..a9f7500189 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -18,7 +18,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 }, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index a7ef6c92e1..527df65d01 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -54,8 +54,9 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// 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; } @@ -93,7 +94,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2) -> Option { } let a = cx.hit(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); @@ -116,7 +117,8 @@ fn grabs(cx: &PageCx<'_>, view: &DocView) -> bool { 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) + // Only the Select tool lets a link through a highlight, and it doesn't come here. + cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) } /// With a Fill & Sign tool, a Fill & Sign mark already on the page is picked up as with the Select @@ -130,11 +132,6 @@ 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)) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index a0840e1b89..699383cbfe 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 { @@ -56,8 +59,38 @@ 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 { + let index = if tool == QuickTool::Link { + links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + } else { + info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + }; + let tip = info.links.iter().find(|l| l.page == page && l.index == index).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: `owner` is the link that holds the press (by its `/Annots` +/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to +/// the tool. +pub(crate) fn page_input(cx: PageToolContext<'_>, 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; @@ -76,26 +109,21 @@ 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 - { - lv.selected = Some((page, l.index)); - lv.context = Some((page, l.index)); + // 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; } if resp.clicked() || resp.double_clicked() { 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; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 889caf4c1c..1ee9676ad2 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,135 @@ 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"); +} From bab615fed13e2204cbb6baf3119f5f8d0c2ffc37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:15:32 +0100 Subject: [PATCH 05/28] refactor(ui): route right-clicks by what lies on top under the pointer Each right-click handler tested the pointer itself, and the chain's order decided which ran: comments only when no earlier module took the frame, Prepare by its own widget scan. A right-click now goes to what lies on top under the pointer (`PageHits::context_at`), by the layer ranking a press uses, with two differences. Comments count with every tool, as a comment's menu opens whatever tool is active (`comments::context_hit`). A highlight over a link is the highlight's: a left click there follows the link, a right-click opens the highlight's menu. On every right-click the page's menu is anchored at the pointer, and the comment it is on is selected, or none (`comments::context`); a Prepare field is selected (`prepare::context`); links take theirs from the same answer. Edit text's own paragraphs and pictures, and a Prepare field tool, keep their right-clicks as before. Behaviour change (the user's ruling, 2026-10-31): a right-click on a highlight over a link opens the highlight's menu, not the link's. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 41 +++++++++++++++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 ++- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 25 ++++++++--- crates/ui-egui/src/tools/prepare.rs | 36 ++++++++-------- crates/ui-egui/tests/it/pointer.rs | 40 ++++++++++++++++++ 6 files changed, 120 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5ca60f8684..5d6dcb8054 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -98,8 +98,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let at = hits.at(view, o); view.router.press(i, at, tool); } - // A right-click goes to what a press there would go to: the top hit under the pointer. - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| top(hits.at(view, p), tool)).map(|h| h.owner); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } @@ -303,6 +302,27 @@ fn link_of(owner: Option) -> Option { } } +/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the +/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is +/// selected; links take theirs from what this returns. +fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let context = hits.context_at(view, p); + // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a + // Prepare field tool the whole page. + let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + if !editing_here && hits.field_tool.is_none() { + let comment = match context { + Some(Owner::Comment { index, .. }) => Some(index), + _ => None, + }; + comments::context(hits.pcx, p, 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, @@ -348,12 +368,25 @@ struct PageHits<'a> { } impl PageHits<'_> { + /// The hits at `p`, bottom up ([`Self::hits`]). + fn at(&self, view: &DocView, p: Pos2) -> Vec { + self.hits(view, p, false) + } + + /// 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`]). + fn context_at(&self, view: &DocView, p: Pos2) -> Option { + 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; 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 { + /// For a right-click (`context`), comments by [`comments::context_hit`]. + fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { page, xf, @@ -415,7 +448,7 @@ impl PageHits<'_> { 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 link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = comments::hit(pcx, view, p, link.is_some()); + 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, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4f9398be85..f9fc72bbcd 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -417,7 +417,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. const HIT_TESTERS: [(&str, &str, &str); 4] = [ - ("canvas/page_input.rs", "at", "`PageHits::at`: every module's hits at a point, for the pointer's cursor and tip and, once, the press's owner"), + ( + "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", + ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index cb8bafe642..d0c46d0453 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::{context, context_hit, describe, fill_grabs, hit, keys, 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/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 527df65d01..a12d971f5c 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -27,12 +27,6 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, 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 }; match cx.tool { QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin), @@ -106,6 +100,25 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op Some(Hit { declines_drag: markup, ..Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, icon) }) } +/// 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; + } + let a = cx.hit(p)?; + let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) +} + +/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the +/// comment it is on ([`context_hit`], when it wins there) is selected, or none. +pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { + view.comments.context_at = Some((cx.page, cx.to_user(p))); + view.comments.selected = index.map(|i| (cx.page, i)); +} + /// 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. diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index cc4b81441e..78568f46db 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -398,24 +398,6 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti 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. @@ -469,6 +451,24 @@ 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. +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 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/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1ee9676ad2..c868dc8079 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1089,3 +1089,43 @@ fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() { 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"); +} From 1780f33350b1b7dc521ecc25bea3fba4e1d02509 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:23:07 +0100 Subject: [PATCH 06/28] refactor(ui): tell each page whether the press is its own Each module asked the router `owner(page).is_none()` to learn whether a press was the active tool's. That reads a press held on another page, or begun between the pages, as the tool's too. The router now remembers the page a press began on (`begin` on every new press, then `press` on the page it lands on), and `press_for(page)` answers what a page has to do with it: - `Mine(owner)`: the press began on this page and the owner's hit won it; - `Free`: no button is down, or the press began on this page and no hit won it, so it is the active tool's; - `Elsewhere`: the press began on another page, or between the pages. The page view asks once per page, and the stamps and the Link tool use the answer. The other modules move to it in the next commits. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/page_input.rs | 10 +-- .../ui-egui/src/canvas/page_pointer/tests.rs | 2 +- crates/ui-egui/src/canvas/page_router.rs | 68 ++++++++++++++++++- 4 files changed, 76 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 289edf811d..428f71c186 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1604,9 +1604,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 { diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 5d6dcb8054..00edfe54cc 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -7,7 +7,7 @@ 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, top}; +use crate::page_router::{Owner, PagePress, top}; use crate::{QuickTool, comments, icons}; /// What the page's tools did with the pointer this frame. @@ -98,6 +98,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); @@ -111,7 +113,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { 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() { + if resp.clicked() && press == PagePress::Free { // Centred on the click, upright as the page is shown. let (vx, vy) = xf.screen_to_view(p); let (w, h) = kind.size(); @@ -136,7 +138,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { 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() { + if resp.clicked() && press == PagePress::Free { // 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); @@ -273,7 +275,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut crate::content_ui::sync_image_selection(view, i, &images, added); } } - let (link_owner, link_context) = (link_of(view.router.owner(i)), link_of(context)); + 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, link_owner, link_context, view); if tool == QuickTool::Select && can_modify diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9fc72bbcd..2e79b7481e 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -384,7 +384,7 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 3] = ["router.press(", "router.settle(", "router.cancel("]; +const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; /// The functions that decide, keep or drop the press the pages hold: (file, function, why). const ROUTER_WRITERS: [(&str, &str, &str); 3] = [ diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index a9f7500189..f1aa199c67 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; @@ -106,6 +107,28 @@ 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: only its module acts on it. + Mine(Owner), + /// No button is down, or the press began on this page and nobody's hit won it: the active + /// tool's. + Free, + /// The press began on another page, or between the pages. + 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 { @@ -118,13 +141,33 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, + /// A press is held, and the page it began on (`None`: between the pages). + 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). 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 }); + 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(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), None) if p == page => PagePress::Free, + _ => PagePress::Elsewhere, + } } /// Who holds the press on `page`. @@ -142,12 +185,14 @@ 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). pub(crate) fn cancel(&mut self) { self.capture = None; + self.began = None; } } @@ -185,6 +230,27 @@ mod tests { 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(); + assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + } + #[test] fn a_highlight_lets_its_drag_go_to_the_text() { let mut r = PageRouter::default(); From 84fe5e54b31ad62ea86feb360aaeaecfbda23b37 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:28:48 +0100 Subject: [PATCH 07/28] refactor(ui): tell comments and Fill & Sign whether the press is theirs Comments ran last in the page view's chain of `||`, so only when no earlier module had taken the frame, and asked the router themselves (`grabs`, `fill_grabs`, `draw_grabs`), with a hit test of their own for hover. A link, a field or added text taking the frame first hid a comment's gesture end, its Escape and the click that unselects it. Comments now run on every frame and are told the page's `PagePress`: - a press on a comment (its body, a handle or a point) goes to `select_input` with the part, with the Select tool, a shape tool (the selected shape's handles) or Fill & Sign (its marks); - the drawing tools act only on a press nobody holds (`Gestures::free`); - a click on anything but a comment leaves comments unselected. `grabs`, `fill_grabs`, `draw_grabs` and `cancelled_drag` (with the raw button read in `keys` it needed) are gone; `grabs_points` is `reaches_points`. Fill & Sign leaves the press to a mark when the press is someone's, or, with no button down, when a mark is the top hit under the pointer. The page's hover is that top hit (`PageInput.hover`), and the comments' hover outline follows it, so it no longer shows under a field or added text that would take a press. Behaviour change (the user's ruling, 2026-10-31): a click on a form field, or on added text, leaves the selected comment unselected, as a click on a link or the page already did. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +- crates/ui-egui/src/canvas/page_input.rs | 37 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 7 +- crates/ui-egui/src/canvas/signature_drag.rs | 3 +- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/geometry.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 115 ++++++++---------- crates/ui-egui/src/tools/comments/paint.rs | 28 +++-- crates/ui-egui/src/tools/comments/state.rs | 2 - crates/ui-egui/src/tools/comments/tests.rs | 36 ++++++ crates/ui-egui/tests/it/pointer.rs | 26 ++++ 11 files changed, 164 insertions(+), 99 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 428f71c186..861833d25a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1496,7 +1496,8 @@ 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 input = + if view.press_cancelled { PageInput { consumed: true, area_tool: false, hover: None } } 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 }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 00edfe54cc..05a302926d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -16,6 +16,8 @@ pub(super) struct PageInput { 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, } /// Common geometry and pointer state for a tool acting on one page. @@ -153,10 +155,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut frame.out.stamp_placed = true; } } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + // A mark already placed takes its own press (comments, below), and with no button down the + // tool's preview hides over one, where a press would pick it up. + let over_mark = matches!(tool, QuickTool::Fill(_)) + && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(&pcx, view) + && !over_mark { match crate::fill_sign::page_input( cx, @@ -283,17 +288,23 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut { view.links.context = Some((i, 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); - } + // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that + // leaves them unselected. + let on_comment = comments::page_input(ui, resp, &pcx, press, view); + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + PageInput { consumed, area_tool, hover } +} + +/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs +/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for +/// hover painting. +fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { + let under = hits.at(view, p); + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); } - PageInput { consumed, area_tool } + top(under, hits.tool) } /// The link `owner` is, by its `/Annots` index. @@ -701,7 +712,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P if input.area_tool { crate::redact_ui::paint(painter, i, view, pointer); } - comments::paint_page(ui, painter, &pcx, view); + 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); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2e79b7481e..865f5051b0 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -217,14 +217,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"), @@ -416,7 +415,7 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 4] = [ +const HIT_TESTERS: [(&str, &str, &str); 3] = [ ( "canvas/page_input.rs", "hits", @@ -424,7 +423,6 @@ const HIT_TESTERS: [(&str, &str, &str); 4] = [ ), ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), - ("tools/comments/input.rs", "grabs", "with no button down, whether a comment would take a press under the pointer, for hover painting"), ]; /// What ignores the press the pages hold until the button is up. @@ -493,7 +491,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ("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"); 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/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index d0c46d0453..83ee620ae1 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::{context, context_hit, describe, fill_grabs, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{context, context_hit, describe, hit, keys, 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 a12d971f5c..7ada11bc7f 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -1,43 +1,47 @@ 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 ([`hit`]) is that comment's, with any tool, and +/// one nobody holds is a commenting tool's. 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, 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; - } - let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; + // A comment being moved, resized or reshaped on this page keeps the frame until it ends. + let moving = + matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == 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, owned, pointer, origin), 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, owned, pointer, origin), _ => false, } } @@ -68,7 +72,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op }; 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) { @@ -119,32 +123,6 @@ pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut view.comments.selected = index.map(|i| (cx.page, i)); } -/// 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; - } - if cx.ptr.any_down { - return matches!(view.router.owner(cx.page), Some(Owner::Comment { .. })) && view.router.keeps_drag(); - } - // Only the Select tool lets a link through a highlight, and it doesn't come here. - cx.ptr.pos.and_then(|p| hit(cx, view, p, false)).is_some_and(|h| !h.declines_drag) -} - -/// 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) -} - -/// 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) -} - /// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat /// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays /// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a @@ -180,13 +158,19 @@ 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)), - _ => None, - }; +/// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the +/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else +/// (a link, a field, added text, the page) leaves comments unselected. +fn select_input( + ui: &egui::Ui, + resp: &egui::Response, + cx: &PageCx<'_>, + view: &mut DocView, + owned: Option<(usize, CommentPart)>, + pointer: Option, + origin: Option, +) -> bool { + let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -262,8 +246,8 @@ 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)); @@ -326,13 +310,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; @@ -380,7 +372,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)]) @@ -412,7 +404,7 @@ impl Gestures<'_> { cv.undo_point(); return true; } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -492,7 +484,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; @@ -500,7 +492,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 { @@ -511,7 +503,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; @@ -519,7 +511,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 { @@ -573,7 +565,7 @@ impl Gestures<'_> { } } } - if resp.clicked() + if self.clicked() && over_page && let Some(p) = pointer { @@ -587,9 +579,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..e784c332ae 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,12 +1,24 @@ -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 @@ -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..07452f5b49 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, 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/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index c868dc8079..b63d9937fb 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1129,3 +1129,29 @@ fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() { right_click(&mut h, on_comment); assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu"); } + +/// 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"); +} From 951ef7e3627288484499d73a7dd9e1ff533283ce Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:34:49 +0100 Subject: [PATCH 08/28] refactor(ui): one gate for the presses nobody holds The tools that act wherever on the page each decided for themselves whether a press was theirs: Sign area, zoom and snapshot, crop, redact and the stamps asked the router, and Measure, Fill & Sign, the camera and the Link tool's rectangle acted on any press. Stamp placement lived inline in the page view. `tool_input` is now the one gate. Measure, the Stamp tool and custom stamps, Fill & Sign, Sign area, zoom and snapshot, the camera and crop are each told whether the press is the tool's (it began on their page and no hit won it), and act only then; their cursors and gestures run as before. Redact's boxes and the Link tool's rectangle are told the same where they are called. Stamp placement moves to `stamps_ui::place` and `place_custom`, beside custom stamps. Measure is told its clicks (`measure_ui::Clicks`), Sign area takes a `PageToolContext`. The camera's corners are hits (`camera_ui::hit`, `Owner::CameraCorner` on the handle layer), so a corner's drag is an owned press like any other: it starts on the corner's own press, ends on `drag_stopped() || !primary_down`, and `cancel_pointer_gestures` drops it. Until now only the order of two key handlers kept Escape mid-drag from leaving a corner following the pointer: Escape leaves the camera tool before the pages see it. The kittest for Escape mid-drag can't fail with that order; a unit test of the cancel comes later in this series. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 + crates/ui-egui/src/canvas/crop.rs | 8 +- crates/ui-egui/src/canvas/page_input.rs | 210 +++++++++--------- .../ui-egui/src/canvas/page_pointer/tests.rs | 6 +- crates/ui-egui/src/canvas/page_router.rs | 2 + crates/ui-egui/src/canvas/zoom_snap.rs | 11 +- crates/ui-egui/src/tools/camera_ui.rs | 57 +++-- crates/ui-egui/src/tools/fill_sign.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 10 +- crates/ui-egui/src/tools/measure_ui.rs | 20 +- crates/ui-egui/src/tools/redact_ui.rs | 10 +- crates/ui-egui/src/tools/sign_ui.rs | 21 +- crates/ui-egui/src/tools/stamps_ui.rs | 50 +++++ crates/ui-egui/tests/it/pointer.rs | 37 +++ 14 files changed, 281 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 861833d25a..30d2799f68 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -863,6 +863,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; 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 index 05a302926d..40552fc1ec 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -39,30 +39,20 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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; @@ -103,106 +93,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // 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); let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); - 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() && press == PagePress::Free { - // 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() && press == PagePress::Free { - // 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; - } - } // A mark already placed takes its own press (comments, below), and with no button down the - // tool's preview hides over one, where a press would pick it up. + // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); - if let QuickTool::Fill(ft) = tool - && allowed - && !over_mark - { - 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; - } + 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, view); frame.out.field_props |= o.properties; @@ -220,7 +115,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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) + 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 @@ -281,7 +176,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } } 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, link_owner, link_context, view); + 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 @@ -315,6 +210,100 @@ fn link_of(owner: Option) -> Option { } } +/// 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. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// pointer. +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`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. @@ -460,9 +449,10 @@ impl PageHits<'_> { // 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 = (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, fields].into_iter().flatten().collect() + [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 865f5051b0..c2d214d0d7 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -150,7 +150,7 @@ 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; 36] = [ +const PAGE_MODULES: [&str; 37] = [ "canvas.rs", "canvas/page_input.rs", "canvas/area.rs", @@ -187,6 +187,7 @@ const PAGE_MODULES: [&str; 36] = [ "canvas/image_pick.rs", "canvas/signature_drag.rs", "tools/calc_order_ui.rs", + "tools/stamps_ui.rs", ]; /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. @@ -434,11 +435,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 10] = [ +const PAGE_HIT_CALLS: [&str; 11] = [ "link_ui::hit(", "prepare::hit(", "forms_ui::hit(", "comments::hit(", + "camera_ui::hit(", "edit_text_ui::hit(", "edit_text_ui::picture_hit(", "edit_text_ui::picture_handle_hit(", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index f1aa199c67..23eae443e9 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -36,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. diff --git a/crates/ui-egui/src/canvas/zoom_snap.rs b/crates/ui-egui/src/canvas/zoom_snap.rs index 159adf2b7e..a422907b89 100644 --- a/crates/ui-egui/src/canvas/zoom_snap.rs +++ b/crates/ui-egui/src/canvas/zoom_snap.rs @@ -13,15 +13,15 @@ use crate::{PdfCraftApp, QuickTool}; /// [x0, y0, x1, y1]); a plain click has an empty rectangle. pub type Marquee = (usize, Rect, [f32; 4]); -/// 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)); @@ -40,7 +40,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..a7e7d6c934 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,28 @@ 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, or on a page +/// without the quadrilateral. +pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { + if view.camera_done.is_some() { + 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 +97,21 @@ 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); - } + if (resp.drag_started() || resp.clicked()) + && press == PagePress::Free + && origin.is_some_and(|o| xf.rect.contains(o)) + && 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 +120,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/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index 045bb64974..416be8867e 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/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 699383cbfe..d380d1b71b 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -87,10 +87,10 @@ fn target_label(info: &DocInfo, target: &LinkTarget) -> String { } } -/// Link tool input on one page: `owner` is the link that holds the press (by its `/Annots` -/// index), and `context` the one a right-click is on. Returns `true` when the gesture belongs to -/// the tool. -pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: Option, view: &mut DocView) -> bool { +/// 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; @@ -128,7 +128,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, owner: Option, context: } return true; } - if resp.drag_started() { + if resp.drag_started() && (free || owner.is_some()) { let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 5008464ad9..a81f01b0b2 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, Debug, Default)] +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/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index e55dd8c5c3..0cca746bf8 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 @@ -256,8 +256,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo if !over_text(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. - if resp.drag_started_by(egui::PointerButton::Primary) && view.router.owner(page).is_none() { + // The tool's own press, of the primary button: one nobody holds. + if resp.drag_started_by(egui::PointerButton::Primary) && free { let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); diff --git a/crates/ui-egui/src/tools/sign_ui.rs b/crates/ui-egui/src/tools/sign_ui.rs index b00d07279a..581fd23694 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 b63d9937fb..9ecc955604 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1155,3 +1155,40 @@ fn a_click_on_a_form_field_deselects_the_selected_comment() { 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"); +} From ed8a10e83e8c933c945aa2437a9a8e31ed41411e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 10:41:07 +0100 Subject: [PATCH 09/28] refactor(ui): tell forms and Prepare whether the press is theirs Forms and Prepare read the router themselves, and forms tested the pointer twice more: for a field's tooltip, and for whether a click came up over the widget it was pressed on. Following a link tested the pointer again too. Both are now told the page's `PagePress`. Prepare's Select tool draws a selection rectangle, or unselects, only on a press nobody holds, and a field tool draws only on such a press. Whether a click came up over what its press began on is answered once, from the hits under the pointer (`released_over`), for fields and for following links. The field tooltip (`forms_ui::tooltip`) and the field hover outline come from the page's hover, the top hit under the pointer. `PageHits::hits` is now the only function that calls a hit test, and the press-handler check no longer lists forms or Prepare. Behaviour changes: a field's tooltip shows only where a press would take the field, not under a comment drawn over it (the user's ruling, 2026-10-31); a field's hover outline follows the same rule, so it shows only with the Select tool, which fills fields. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 7 ++- crates/ui-egui/src/canvas/page_input.rs | 20 +++++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 26 ++-------- crates/ui-egui/src/tools/forms_ui.rs | 47 ++++++++++++------- crates/ui-egui/src/tools/prepare.rs | 35 +++++++++----- crates/ui-egui/tests/it/pointer.rs | 34 ++++++++++++++ 6 files changed, 111 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 30d2799f68..6cb3211c7c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1499,8 +1499,11 @@ 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, hover: None } } else { page_tool_input(ui, view, frame, &slot) }; + let input = if view.press_cancelled { + PageInput { consumed: true, area_tool: false, hover: None, released_over: 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 }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 40552fc1ec..38c20196d9 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -18,6 +18,9 @@ pub(super) struct PageInput { 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. @@ -92,6 +95,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // 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); + let released_over = match press { + PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + _ => false, + }; let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. @@ -99,13 +106,13 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), 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, view); + 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, view) + 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; @@ -188,7 +195,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); - PageInput { consumed, area_tool, hover } + PageInput { consumed, area_tool, hover, released_over } } /// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs @@ -727,7 +734,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } } } else { - crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + 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 @@ -743,8 +751,8 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P && resp.clicked() && tool == QuickTool::Select && !input.consumed - && let Some(owner @ Owner::Link { index }) = view.router.owner(i) - && pointer.and_then(|p| crate::link_ui::hit(&xf, info, i, doc_links, tool, p)).is_some_and(|h| h.owner == owner) + && 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()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index c2d214d0d7..48bb408bad 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -416,15 +416,11 @@ fn hit_test_calls(line: &str) -> Vec { /// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the /// press point; the others ask where the pointer is. -const HIT_TESTERS: [(&str, &str, &str); 3] = [ - ( - "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", - ), - ("canvas/page_input.rs", "page_overlays", "a link's click counts only released over the link its press was on"), - ("tools/forms_ui.rs", "page_input", "the field's tooltip under the pointer, and a click counts only released over the widget its press was on"), -]; +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", +)]; /// What ignores the press the pages hold until the button is up. const PRESS_IGNORES: [&str; 1] = ["press_cancelled = true"]; @@ -487,8 +483,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { )); 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"), @@ -496,16 +490,6 @@ fn page_modules_act_on_a_press_only_through_the_router() { ] { 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")); } 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/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 78568f46db..ffd716c056 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; @@ -373,9 +382,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti if !allowed { return out; } + let free = press == PagePress::Free; if let Some(tool) = tool { view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair); - if resp.drag_started() || resp.clicked() { + if free && (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; @@ -398,18 +408,17 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti return out; } - // 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 + // The Select tool: what the press is on ([`hit`]), a corner handle or a field; a press nobody + // holds 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)), + 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()) { + if resp.drag_started() && (held || free) { let origin = ptr.origin.unwrap_or(p); - if let Some(Owner::PrepareCorner { left, top }) = owner { + 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 { @@ -432,7 +441,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. diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9ecc955604..6975a12a04 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1192,3 +1192,37 @@ fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() { 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"); +} From 734cb4c13a4aa2e2ba1ae201f9fcf905d227d3ee Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:26:10 +0100 Subject: [PATCH 10/28] test(ui): the page guards read past a test module in the middle of a file The page guards (pointer reads, cursor writes, and presses) left a file's tests out by cutting it at its first `#[cfg(test)] mod`. `edit_text_ui/image.rs` has a test module at line 231, so its lines 245-1116 were never read: `crop_input`, `image_input` and the picture hit tests among them. A hit test called there, a router write or a raw pointer read passed every guard. `outside_test_modules` now leaves out each test module up to its closing brace, and the guards read the rest. Nothing in the newly read code breaks a rule. A unit test pins the scanner, and a bare `crop_hit(` planted in `image_input` now fails the press guard (it passed before). The guard's messages and docs, CLAUDE.md's "Page presses" line, and the docs of `page_tool_input`, `PageHits::hits` and `PageRouter::cancel` now say what the code does: modules are told what a press is to their page (`PagePress`), and `PageHits::hits` is the one function that asks the hit tests. `comments::context_hit(` joins the hit tests a press handler must not call. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 12 ++--- .../ui-egui/src/canvas/page_pointer/tests.rs | 50 +++++++++++++++---- crates/ui-egui/src/canvas/page_router.rs | 6 ++- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b1d7f5023..47a80b86bc 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_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the router says the press is its own: the page view tells it what the press is to its page (`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/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 38c20196d9..a2f71d97c8 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,9 @@ pub(crate) struct PageToolContext<'a> { 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. +/// The tools' input on one page. The hits at the press point decide who holds the press, and each +/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, +/// through `tool_input`), or another page's. 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, @@ -391,9 +391,9 @@ 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). + /// 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`]. fn hits(&self, view: &DocView, p: Pos2, context: bool) -> Vec { let PageHits { diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 48bb408bad..69abae12cf 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -264,6 +264,25 @@ 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 +} + /// 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). fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, Option, usize, String)> { @@ -271,11 +290,9 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, 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"); - // 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() { + for (n, line) in outside_test_modules(&source) { if line.starts_with("impl") { (function, in_impl) = (None, true); } else if line == "}" { @@ -287,7 +304,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, continue; } for name in find(line) { - found.push((file, function.map(str::to_owned), n + 1, name)); + found.push((file, function.map(str::to_owned), n, name)); } } } @@ -414,8 +431,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). Only `PageHits::at` asks at the -/// press point; the others ask where the pointer is. +/// The functions that call a hit test: (file, function, why). `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", @@ -431,11 +449,12 @@ const PRESS_IGNORERS: [(&str, &str, &str); 1] = /// The module hit tests, called by path: a press handler calls none of them (HIT_TESTERS says who /// may). -const PAGE_HIT_CALLS: [&str; 11] = [ +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(", @@ -461,7 +480,7 @@ fn routed_press_handler(code: &str) -> bool { /// 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::at` asks the hit tests at the press point. Escape is the one way to +/// 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_act_on_a_press_only_through_the_router() { @@ -469,7 +488,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { &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): a page module reads `view.router.owner(page)`. Drop a press with \ + (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.", @@ -499,8 +518,8 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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::at`: act on the press when \ - `view.router.owner(page)` says it is yours, rather than testing its point again. Testing where the pointer \ + "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. Testing where the pointer \ is (a tooltip, a click released over what it was pressed on)? 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.", @@ -516,6 +535,15 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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() {}")]); +} + #[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 23eae443e9..447289ccd0 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -191,7 +191,8 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel). + /// Drops the press (Escape, a dialog, 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; @@ -250,7 +251,8 @@ mod tests { assert_eq!((r.press_for(1), r.press_for(2)), (PagePress::Free, PagePress::Free)); r.begin(); r.cancel(); - assert_eq!(r.press_for(0), PagePress::Free, "a cancelled press is nobody's"); + // 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"); } #[test] From b1a0657c426076ca7576dfbf15853463c02f846d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:29:46 +0100 Subject: [PATCH 11/28] fix(ui): right-clicks with Prepare, the Link tool and Add text as before Routing right-clicks by what lies on top under the pointer changed three cases beyond the rulings: - **Prepare.** A right-click while a field was dragged selected the field under the pointer, and the release then moved that one. Prepare used to ignore right-clicks during a drag; it does again (`prepare::context` returns while a grab is held). - **The Link tool.** A note over a link took the right-click, though the Link tool's press there is the link's. The Link tool's right-click on a link is the link's again, whatever lies over it. - **Add text.** Comments took right-clicks away from paragraphs and added items. Add text used to take every right-click on a page it can change; it does again. Tests: those three, and two that pin ruling 6's right-click half with the Select tool: no link menu on a link with no action, and none on a form field over a link. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 20 +++++++-- crates/ui-egui/src/tools/prepare.rs | 6 ++- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++++++ crates/ui-egui/tests/it/prepare.rs | 37 ++++++++++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index a2f71d97c8..7f5f9dd154 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -316,9 +316,13 @@ fn tool_input( /// selected; links take theirs from what this returns. fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); - // Edit text's own paragraphs and pictures keep the right-click as they take it today, and a - // Prepare field tool the whole page. - let editing_here = matches!(hits.tool, QuickTool::AddText | QuickTool::EditText) && top(hits.at(view, p), hits.tool).is_some(); + // Add text takes every right-click on a page it can change, Edit text those on its own + // paragraphs and pictures, and a Prepare field tool the whole page, as before. + let editing_here = match hits.tool { + QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), + QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + _ => false, + }; if !editing_here && hits.field_tool.is_none() { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), @@ -385,8 +389,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). + /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as + /// its press there is. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - top(self.hits(view, p, true), QuickTool::Select).map(|h| h.owner) + let hits = self.hits(view, p, true); + if self.tool == QuickTool::Link + && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + { + return Some(link.owner); + } + top(hits, QuickTool::Select).map(|h| h.owner) } /// The hits at `p`, bottom up: links, comments; Edit text's pictures under its paragraphs (text diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index ffd716c056..9c659352f9 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -462,11 +462,15 @@ pub(crate) fn page_input( /// 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. +/// 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) { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6975a12a04..306f98accc 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1226,3 +1226,61 @@ fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() { 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()); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 82e33b29de..281e3d9e18 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1562,3 +1562,40 @@ 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"); +} From b12d091496348088bde8bd592dd3d9e3fb3cc91f Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 12:35:25 +0100 Subject: [PATCH 12/28] fix(ui): hover painting beside the open palette, and one hit test under the pointer The page's hover (the top hit under the pointer) was only worked out when nothing was drawn over the pages, so with the palette open a field beside it lost its tooltip and its hover outline, and comments their outline. The palette keeps the page's cursor and tips away, which is what that gate is for; hover now ignores it, as the outlines and the field tooltip did before. The hits under the pointer were also asked up to four times a page each frame, each pass running every module's hit test: for a click's release, for Fill & Sign's preview, for a right-click, and for the cursor. A click's release, Fill & Sign's preview and a right-click's tool checks now share one pass, taken before the tools act and only on frames that need it. A right-click's own pass (comments by `context_hit`) stays, and the cursor and hover keep theirs, after the tools act. A unit test pins that cancelling the pages' gestures lets go of a camera corner. The kittest for Escape mid corner drag passes on `main` too, because Escape leaves the camera tool first. Tests: `a_fields_tooltip_shows_beside_the_open_palette`, `cancelling_pointer_gestures_lets_go_of_a_camera_corner`; each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 35 +++++++++++++++---------- crates/ui-egui/src/canvas/tests.rs | 10 +++++++ crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 7f5f9dd154..f1d458257e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -95,15 +95,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // 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, asked once: for a click's release, a + // right-click, and Fill & Sign's preview. + let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; + let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { - PagePress::Mine(owner) if resp.clicked() => pointer.is_some_and(|p| hits.at(view, p).iter().any(|h| h.owner == owner)), + PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, view, p)); + let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = matches!(tool, QuickTool::Fill(_)) - && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && pointer.and_then(|p| top(hits.at(view, p), tool)).is_some()); + let over_mark = + matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !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); @@ -194,17 +198,19 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // leaves them unselected. let on_comment = comments::page_input(ui, resp, &pcx, press, view); let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; - let hover = pointer.filter(|_| frame.env.unobstructed).and_then(|p| claim_under(&hits, view, p)); + let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); PageInput { consumed, area_tool, hover, released_over } } -/// The cursor and the tip from what lies under the pointer at `p`, after the tools claimed theirs -/// (the last claim on a layer stands). Returns the top of it, what a press there would take, for -/// hover painting. -fn claim_under(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// 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); - for h in &under { - view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + if claim { + for h in &under { + view.cursor.claim_with_tip(h.layer, h.icon, h.tip.clone()); + } } top(under, hits.tool) } @@ -314,13 +320,14 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is /// selected; links take theirs from what this returns. -fn right_click(hits: &PageHits<'_>, view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. +fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, and a Prepare field tool the whole page, as before. let editing_here = match hits.tool { - QuickTool::AddText => hits.can_modify || top(hits.at(view, p), hits.tool).is_some(), - QuickTool::EditText => top(hits.at(view, p), hits.tool).is_some(), + QuickTool::AddText => hits.can_modify || top(under.iter().cloned(), hits.tool).is_some(), + QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), _ => false, }; if !editing_here && hits.field_tool.is_none() { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 4761584735..02aeeacd09 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2037,3 +2037,13 @@ fn image_handles_account_for_document_and_view_rotation() { } } } + +/// Cancelling the pages' pointer gestures (Escape, a dialog, 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)); +} diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 306f98accc..0e5a7e3af1 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1284,3 +1284,17 @@ fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() { 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"); +} From ce90c81ffb065e2d4f48bcb0926732c92773e2e1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:06:29 +0100 Subject: [PATCH 13/28] fix(ui): a drag seen past its page's edge still draws on that page egui reports a drag once the pointer has moved a few points from the press. A press near a page's bottom edge, flicked onto the next page in one frame, is seen as a drag only with the pointer on the next page. Redact (and the area Highlight), the Link tool and Prepare returned before their drag start unless the pointer was on their own page, and the next page is `Elsewhere`, so the press drew nothing anywhere. (On main the next page took it, with the box's start on the page before.) They now start the gesture from where the press began on their own page, as zoom, snapshot, crop and Sign area already did, so the box, link rectangle, marquee, field drawn or field moved belongs to the page the press began on. Tests: one per tool, each seen failing with the fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/link_ui.rs | 19 ++++--- crates/ui-egui/src/tools/prepare.rs | 73 +++++++++++++-------------- crates/ui-egui/src/tools/redact_ui.rs | 20 ++++---- crates/ui-egui/tests/it/pointer.rs | 58 +++++++++++++++++++++ 4 files changed, 117 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index d380d1b71b..9cd62d6969 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -108,7 +108,19 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option, free: bool, owner: Option form.get(field).map(|f| (f, widget)), _ => None, }; - if resp.drag_started() && (held || free) { - let origin = ptr.origin.unwrap_or(p); - if let PagePress::Mine(Owner::PrepareCorner { left, top }) = press { + // 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) { @@ -432,7 +409,29 @@ pub(crate) fn page_input( 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; } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 0cca746bf8..345e7e5d77 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -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: one nobody holds. - if resp.drag_started_by(egui::PointerButton::Primary) && free { - 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/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 0e5a7e3af1..8a020cc4ea 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1298,3 +1298,61 @@ fn a_fields_tooltip_shows_beside_the_open_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"); +} From 6557d6edc5d8761dcc26394391f7f7e52f12a62e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:12:47 +0100 Subject: [PATCH 14/28] test(ui): the page guard holds told modules to what they are told The modules that are now told what a press is to their page (forms, Prepare, comments, the Link tool, the camera, the stamps and the other free-press tools) left the press-handler list, whose check was only that each read `router.owner(` and called no hit test of its own. Nothing kept them from reading the router again, though CLAUDE.md said the guard enforced it. - Rule (e) now: `router.press_for(`, `router.owner(` and `router.keeps_drag(` are read only by `ROUTER_READERS`: the page view, and the Edit text group and text layer until 4b tells them too. An unused entry fails the test, so 4b removes them one by one. - A hit test called as a method counts as a call: `cx.hit(origin)` or `self.foo_hit(p)` in a press handler passed. `PageCx::hit`, which finds a comment and makes no `Hit`, is renamed `comment_at`. - An entry of `PAGE_HIT_CALLS` that no page module calls any more fails the test. Each was seen: a `view.router.owner(page)` and a `cx.hit(origin)` planted in forms' `page_input` pass the guard before and fail it after, and a stale `PAGE_HIT_CALLS` entry fails it. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/canvas/page_pointer/tests.rs | 42 +++++++++++++++++-- crates/ui-egui/src/tools/comments/input.rs | 4 +- crates/ui-egui/src/tools/comments/paint.rs | 2 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 69abae12cf..dd37e44373 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -418,15 +418,14 @@ fn is_hit_test(name: &str) -> bool { name == "hit" || name.ends_with("_hit") } -/// The hit tests `line` calls, by path or bare. A method, after its `.`, is left out: -/// `PageCx::hit` finds the comment under a point, not a `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('.') && !before.ends_with("fn ")).then(|| name.to_owned()) + (is_hit_test(name) && !before.ends_with("fn ")).then(|| name.to_owned()) }) .collect() } @@ -440,6 +439,27 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`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", )]; +/// What reads who holds the press the pages hold. +const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.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 and +/// the text layer 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"]; @@ -493,6 +513,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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_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.", + )); problems.extend(misuses( &PRESS_IGNORES, &PRESS_IGNORERS, @@ -524,6 +552,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { src/canvas/page_pointer/tests.rs, with why.", "calls no hit test any more: remove it from HIT_TESTERS.", )); + // An entry no page module calls any more no longer guards anything. + let called = page_module_uses(&PAGE_HIT_CALLS); + problems.extend( + PAGE_HIT_CALLS + .iter() + .filter(|call| !called.iter().any(|(_, _, _, name)| name == *call)) + .map(|call| format!("no page module calls `{call}` any more: remove it from PAGE_HIT_CALLS.")), + ); let builds = page_module_uses(&HIT_BUILDS); assert!(!builds.is_empty(), "no page module makes a `Hit` any more: update HIT_BUILDS"); problems.extend(builds.into_iter().filter(|(_, function, _, _)| !function.as_deref().is_some_and(is_hit_test)).map(|(file, _, n, name)| { diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 7ada11bc7f..396aea8b13 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -90,7 +90,7 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op 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 { return None; @@ -111,7 +111,7 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { if cx.hidden || !cx.xf.rect.contains(p) { return None; } - let a = cx.hit(p)?; + let a = cx.comment_at(p)?; let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) } diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index e784c332ae..8faae3d02e 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -24,7 +24,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_> // 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; diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 07452f5b49..9c876d950f 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -384,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) } From ae62b66304a3e4b089fe7d3da7138cc5d44b885c Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:16:14 +0100 Subject: [PATCH 15/28] fix(ui): right-clicks with the Link tool and the camera as before; true docs Routing right-clicks changed a few more cases beyond the rulings, found in review: - **The Link tool.** A right-click on a link also unselected the selected comment and moved the page menu's anchor; the Link tool used to take it whole. On a document it can't change, its right-click on a note over a link opened the plain page menu instead of the note's. Now the Link tool's right-click is what its press there would take, on a document it can change, and on a link it leaves comments alone. - **The camera.** A corner, on the handle layer, took right-clicks from a comment under it, though it has no menu. Corners are no longer right-click targets. - **The Link tool's hand** showed in the 2 px margin past the page's edge, where a press is no page's. `link_ui::hit` now finds nothing off the page. Docs made true: what a comment's press is (`comments::page_input`), what reaches the free-press gate (`page_tool_input`), and when every page is `PagePress::Free`. Tests: those three, and two that pin right-clicks kept as before: Edit text's on a paragraph under a comment, and a Prepare field tool's. Each seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 34 +++++---- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/src/tools/comments/input.rs | 5 +- crates/ui-egui/src/tools/link_ui.rs | 4 + crates/ui-egui/tests/it/pointer.rs | 85 ++++++++++++++++++++++ 5 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f1d458257e..eda260451d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -35,9 +35,10 @@ pub(crate) struct PageToolContext<'a> { pub info: &'a DocInfo, } -/// The tools' input on one page. The hits at the press point decide who holds the press, and each -/// module is told what the press is to this page (`PagePress`): its own, nobody's (the active tool's, -/// through `tool_input`), or another page's. Text selection runs after, on what nobody took. +/// 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, @@ -324,13 +325,15 @@ fn tool_input( fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { let context = hits.context_at(view, p); // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, and a Prepare field tool the whole page, as before. - let editing_here = match hits.tool { + // paragraphs and pictures, 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 || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), - _ => false, + QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), + _ => hits.field_tool.is_some(), }; - if !editing_here && hits.field_tool.is_none() { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -396,16 +399,16 @@ impl PageHits<'_> { /// What a right-click at `p` is on: what lies on top there, as for a press, but with every /// tool reaching comments and links, as a comment's menu shows whatever the tool. A highlight /// over a link is the highlight's ([`comments::context_hit`]). - /// The Link tool edits links, so its right-click on one is the link's whatever lies over it, as - /// its press there is. + /// 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, when that is anything. fn context_at(&self, view: &DocView, p: Pos2) -> Option { - let hits = self.hits(view, p, true); if self.tool == QuickTool::Link - && let Some(link) = hits.iter().rev().find(|h| matches!(h.owner, Owner::Link { .. })) + && self.can_modify + && let Some(press) = top(self.hits(view, p, false), QuickTool::Link) { - return Some(link.owner); + return Some(press.owner); } - top(hits, QuickTool::Select).map(|h| h.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 @@ -413,7 +416,8 @@ impl PageHits<'_> { /// open paragraph's frame, the selected picture's grips over them all; the camera tool's corners; /// then form fields (Prepare's, being moved, over the comments; filled ones under added content /// and the comments that move). In crop mode, only the picture being cropped. - /// For a right-click (`context`), comments by [`comments::context_hit`]. + /// 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, @@ -475,7 +479,7 @@ impl PageHits<'_> { // 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 = (tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, 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() diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 447289ccd0..0b53038625 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -115,7 +115,8 @@ pub(crate) enum PagePress { /// The press began on this page, and the owner's hit won it: only its module acts on it. Mine(Owner), /// No button is down, or the press began on this page and nobody's hit won it: the active - /// tool's. + /// tool's. Also every page's while a press that began off the pages is held, and after the + /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, /// The press began on another page, or between the pages. Elsewhere, diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 396aea8b13..52319276da 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -9,8 +9,9 @@ use crate::page_cursor::{Layer, Subject}; use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// 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 ([`hit`]) is that comment's, with any tool, and -/// one nobody holds is a commenting tool's. Returns `true` when the pointer gesture belongs to +/// 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. 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, view: &mut DocView) -> bool { let (pointer, origin) = (cx.ptr.pos, cx.ptr.origin); diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 9cd62d6969..659bad41e4 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -66,6 +66,10 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { /// 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 = if tool == QuickTool::Link { links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index } else { diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8a020cc4ea..04d98b9318 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1356,3 +1356,88 @@ fn a_field_dragged_off_its_page_is_drawn_on_it() { 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. +#[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. +#[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); +} + +/// 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); +} From 47ece03a5465dd22355c88c001b583d925b811fc Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 13:48:54 +0100 Subject: [PATCH 16/28] fix(ui): the Link tool's click told too; a moved comment keeps its selection Found in review: - **The Link tool's click** acted on any press: with Prepare open, a click on a field also unselected the selected link, and a click that began off the page did too. It now acts only on a press on a link, or one nobody holds. - **A right-click while a comment is moved** selected the comment under it mid-drag, though the release still moved the first. Comments now keep their selection during a move, resize or reshape, as Prepare does. - The hits under the pointer before the tools act are taken only on the pointer's page, and the Link tool's right-click reuses them. - The router has one source for the press's page (`Capture` no longer carries its own). - The guard lists which functions may ask which comment lies under a point (`comment_at`); the guard finds hit tests by name, and its docs say so. - Docs made true: what `PagePress::Mine` promises, what `tool_input` gates, and that a dialog does not cancel the pages' press (only Escape and automation's cancel do). Tests: the two fixes, and ruling 1 for added text (a click on it unselects the comment), each seen failing with its fix reverted; the drawing-tool right-click test now checks the comment's menu. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 19 ++++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 ++++++- crates/ui-egui/src/canvas/page_router.rs | 12 ++-- crates/ui-egui/src/canvas/tests.rs | 2 +- crates/ui-egui/src/tools/link_ui.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 64 ++++++++++++++++++- 6 files changed, 106 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index eda260451d..248b5a767f 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -99,7 +99,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // What lies under the pointer before the tools act, asked once: for a click's release, a // right-click, and Fill & Sign's preview. let wants_under = resp.clicked() || resp.secondary_clicked() || matches!(tool, QuickTool::Fill(_)) && !ptr.any_down; - let under = pointer.filter(|_| wants_under).map(|p| hits.at(view, p)).unwrap_or_default(); + let under = pointer.filter(|p| wants_under && xf.rect.contains(*p)).map(|p| hits.at(view, p)).unwrap_or_default(); let released_over = match press { PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, @@ -227,7 +227,8 @@ fn link_of(owner: Option) -> Option { /// 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. `over_mark`: a Fill & Sign mark takes the press, or would take one under the +/// 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`: a Fill & Sign mark takes the press, or would take one under the /// pointer. fn tool_input( ui: &egui::Ui, @@ -323,7 +324,7 @@ fn tool_input( /// selected; links take theirs from what this returns. /// `under`: the hits at `p` before the tools act. fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { - let context = hits.context_at(view, p); + let context = hits.context_at(view, p, under); // Add text takes every right-click on a page it can change, Edit text those on its own // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole // page, as before: comments see none of them. @@ -333,7 +334,10 @@ fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mu QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - if !tool_takes_it { + // A comment being moved, resized or reshaped keeps its selection until its release. + let comment_dragged = + matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); + if !tool_takes_it && !comment_dragged { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, @@ -400,11 +404,12 @@ impl PageHits<'_> { /// 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, when that is anything. - fn context_at(&self, view: &DocView, p: Pos2) -> Option { + /// 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(self.hits(view, p, false), QuickTool::Link) + && let Some(press) = top(under.iter().cloned(), QuickTool::Link) { return Some(press.owner); } diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index dd37e44373..5919fdae80 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -407,7 +407,7 @@ const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.sett 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, a dialog, automation's cancel"), + ("canvas.rs", "cancel_pointer_gestures", "every pointer gesture's cancel: Escape, automation's cancel"), ]; /// Ways to make a `Hit`. @@ -430,7 +430,9 @@ fn hit_test_calls(line: &str) -> Vec { .collect() } -/// The functions that call a hit test: (file, function, why). `PageHits::hits` is the one: the hits +/// 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); a point test by another +/// name has a list of its own (`COMMENT_POINT_TESTERS`). `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] = [( @@ -439,6 +441,16 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`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 comments' point test, which their hit tests are built on: which comment lies under a point. +const COMMENT_POINT_TESTS: [&str; 1] = [".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; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; @@ -521,6 +533,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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.", )); + 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( &PRESS_IGNORES, &PRESS_IGNORERS, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0b53038625..be83411072 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -112,7 +112,8 @@ 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: only its module acts on it. + /// 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 that began off the pages is held, and after the @@ -135,7 +136,6 @@ impl PagePress { /// 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, } @@ -159,7 +159,7 @@ impl PageRouter { /// 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). 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 }); + self.capture = top(hits, tool).map(|h| Capture { owner: h.owner, declines_drag: h.declines_drag }); self.began = Some(Some(page)); } @@ -167,7 +167,7 @@ impl PageRouter { pub(crate) fn press_for(&self, page: usize) -> PagePress { match (self.began, &self.capture) { (None, _) => PagePress::Free, - (Some(Some(p)), Some(c)) if p == page => PagePress::Mine(c.owner), + (Some(Some(p)), Some(capture)) if p == page => PagePress::Mine(capture.owner), (Some(Some(p)), None) if p == page => PagePress::Free, _ => PagePress::Elsewhere, } @@ -175,7 +175,7 @@ impl PageRouter { /// 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 @@ -192,7 +192,7 @@ impl PageRouter { } } - /// Drops the press (Escape, a dialog, automation's cancel): every page is the active tool's again + /// 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; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 02aeeacd09..c0a797fa67 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2038,7 +2038,7 @@ fn image_handles_account_for_document_and_view_rotation() { } } -/// Cancelling the pages' pointer gestures (Escape, a dialog, automation's cancel) lets go of a +/// 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() { diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 659bad41e4..069564afff 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -132,7 +132,8 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, free: bool, owner: Option Vec { } /// 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. +/// 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); @@ -1441,3 +1442,64 @@ fn a_right_click_with_a_field_tool_selects_no_comment() { 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"); +} From aa415811d7462d17978a9f17f3b6190681cc8710 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 14:22:54 +0100 Subject: [PATCH 17/28] fix(ui): right-clicks leave a held press's selections alone; guard finds more page modules Found in review: - **Right-clicks during a press.** A comment being moved kept its selection only while its gesture state was live, so a gesture left over (a release the pages never saw) blocked every later right-click. Comments and Prepare now leave their selection alone while the primary button is held. egui ends the press's own interaction (and its drag) when another button goes down, so the button is what tells. A touch's long press, which egui reports as a right-click with the finger still down, still opens its menu. - **A click that began beside the page** unselected the selected comment, though the Link tool and Prepare now leave theirs alone for such a click. Comments do the same. - **The guard** took a file as a page module only if it named `PagePointer`; a file using `PageToolContext`, `PagePress` or the router's hits or owners is one too. Its message about tooltips and release checks now points at the page view's own hits, and its doc of the router's readers names link following. Tests: a right-click with a gesture left over, a click that began beside the page, and a long press on a comment. Each was seen failing with its fix reverted. A planted page-router name in a module the guard didn't list fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 28 ++++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 24 +++++++---- crates/ui-egui/src/tools/comments/input.rs | 19 +++++---- crates/ui-egui/tests/it/pointer.rs | 41 +++++++++++++++++++ 4 files changed, 87 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 248b5a767f..4a9ed3a54e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -104,7 +104,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PagePress::Mine(owner) if resp.clicked() => under.iter().any(|h| h.owner == owner), _ => false, }; - let context = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p)); + // 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 another button + // goes down, 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 = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); // A mark already placed takes its own press (comments, below), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -322,29 +327,30 @@ fn tool_input( /// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the /// page's 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. -fn right_click(hits: &PageHits<'_>, under: &[crate::page_router::Hit], view: &mut DocView, p: Pos2) -> Option { +/// `under`: the hits at `p` before the tools act. `press_held`: the primary button is held, and the +/// selections its press acts on are left alone until its release. +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); - // Add text takes every right-click on a page it can change, Edit text those on its own - // paragraphs and pictures, the Link tool those on a link, and a Prepare field tool the whole - // page, as before: comments see none of them. + // Add text takes every right-click on a page it can change, Edit text those on anything of its + // own (paragraphs, pictures, added content, the objects selected together and their grips), 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 || top(under.iter().cloned(), hits.tool).is_some(), QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), QuickTool::Link => hits.can_modify && matches!(context, Some(Owner::Link { .. })), _ => hits.field_tool.is_some(), }; - // A comment being moved, resized or reshaped keeps its selection until its release. - let comment_dragged = - matches!(view.comments.gesture, Some(comments::Gesture::Move { .. } | comments::Gesture::Resize { .. } | comments::Gesture::Reshape { .. })); - if !tool_takes_it && !comment_dragged { + if !tool_takes_it && !press_held { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; comments::context(hits.pcx, p, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context { + if let Some(Owner::PrepareField { field, widget }) = context + && !press_held + { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 5919fdae80..1c41858651 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,6 +190,13 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; +/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to +/// a page, or the router's hits and owners. Such a file is a page module. +const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_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()", @@ -355,7 +362,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") { @@ -375,8 +382,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.")); } } } @@ -455,8 +463,9 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.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 and -/// the text layer still ask, until they are told too (PR 4b), and an unused entry fails the test. +/// 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", @@ -567,8 +576,9 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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. Testing where the pointer \ - is (a tooltip, a click released over what it was pressed on)? Add the function to HIT_TESTERS in \ + 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.", )); diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 52319276da..4c026604ea 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -35,14 +35,14 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free }; match cx.tool { // 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, owned, pointer, origin), + QuickTool::Comment(_) | QuickTool::Fill(_) if owned.is_some() || moving => select_input(ui, resp, cx, view, press, pointer, origin), 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, owned, pointer, origin), + QuickTool::Select => select_input(ui, resp, cx, view, press, pointer, origin), _ => false, } } @@ -160,18 +160,22 @@ pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option } /// The Select tool: select, move, resize, edit, delete, on the comment the press is on and the -/// part of it (`owned`, from [`hit`]). The cursor comes from [`hit`] too. A click on anything else -/// (a link, a field, added text, the page) leaves comments unselected. +/// 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, - owned: Option<(usize, CommentPart)>, + press: PagePress, pointer: Option, origin: Option, ) -> bool { - let owner = owned.and_then(|(index, part)| cx.get(index).map(|a| (a, part))); + let owner = match press { + PagePress::Mine(Owner::Comment { index, part }) => cx.get(index).map(|a| (a, part)), + _ => None, + }; let cv = &mut view.comments; let mut consumed = false; if resp.drag_started() @@ -255,7 +259,8 @@ fn select_input( cv.reveal = true; consumed = true; } - None => cv.selected = None, + None if press != PagePress::Elsewhere => cv.selected = None, + None => {} } } if resp.double_clicked() diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d318818304..6685085614 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1503,3 +1503,44 @@ fn a_click_on_added_text_deselects_the_selected_comment() { 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); +} From f0caaf9259cf37fcb892b664e6aaa4890a52280e Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 17:46:08 +0100 Subject: [PATCH 18/28] fix(ui): a press Prepare or added content took draws no comment; menus open where right-clicked Found in review: - **Comments acted on presses another tool had taken.** Comments run every frame now, and a commenting tool acted on any press nobody held, so with Prepare open a Rectangle drag drew a rectangle as well as Prepare's selection rectangle. Added content, with the Edit panel open, could likewise open a note composer under the Note tool. `main` skipped comments when another module took the frame. They are now told (`taken`), and their drawing and placing tools leave such a press alone. A click there still unselects. - **The page menu's anchor went stale.** A right-click while the primary button was held skipped the call that also anchors the page menu, so Delete page, Paste and Copy Image acted on the page right-clicked before. Every right-click on a page now anchors the menu there, whoever takes it, and only the selections wait for the release. That includes the right-clicks Add text, Edit text, the Link tool and a field tool take, which used to keep the previous anchor. - **Edit text's right-clicks.** Edit text took right-clicks on added content too. It now takes those on its paragraphs, pictures and the objects selected together, and comments see a right-click on added text, as before. - **Camera corners.** A camera corner at the page's edge showed its grab cursor past the edge, where a press is no page's. Its hit now stops at the page, as a link's does. - One source for a comment's layer, dead code in Add text's right-click, and true docs for `PagePress::Elsewhere` and the "Page presses" rule. Tests: a shape-tool drag with Prepare open, a right-click with the button held on another page, a right-click on added text with Edit text, the menu anchored by an Edit text right-click, and a camera corner at the page's edge. Each was seen failing with its fix reverted. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- crates/ui-egui/src/canvas/page_input.rs | 40 ++++++++++-------- crates/ui-egui/src/canvas/page_router.rs | 2 +- crates/ui-egui/src/canvas/tests.rs | 11 +++++ crates/ui-egui/src/tools/camera_ui.rs | 7 ++-- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 35 ++++++++++------ crates/ui-egui/tests/it/pointer.rs | 47 +++++++++++++++++++++- crates/ui-egui/tests/it/prepare.rs | 18 +++++++++ 9 files changed, 127 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47a80b86bc..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: the page view tells it what the press is to its page (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. Escape cancels any press the pages hold (`cancel_on_escape`), and that press is ignored until the button is up. `page_pointer::tests::page_modules_act_on_a_press_only_through_the_router` enforces it. +- **Page presses.** A page module decides what of its own is under a point in one hit test, which returns a `Hit` (owner, layer, cursor, tip; `crates/ui-egui/src/canvas/page_router.rs`) and gives its cursor, its tip and its press. It acts on a press only when the press is its own or, for the active tool, nobody's: the page view tells it which (`PagePress`: its own, nobody's, or another page's), and it never tests the press point again or asks another module whether it wants it. 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/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 4a9ed3a54e..647a39d970 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -201,9 +201,12 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut view.links.context = Some((i, index)); } // Comments, every frame: their own presses, a commenting tool's, and a click elsewhere that - // leaves them unselected. - let on_comment = comments::page_input(ui, resp, &pcx, press, view); - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || on_comment; + // 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 } } @@ -324,33 +327,36 @@ fn tool_input( } } -/// A right-click at `p`: it goes to what lies on top there ([`PageHits::context_at`]), which the -/// page's menu then acts on. A comment there is selected, and none elsewhere; a Prepare field is -/// selected; links take theirs from what this returns. +/// 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 /// selections its press acts on are left alone until its release. 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); - // Add text takes every right-click on a page it can change, Edit text those on anything of its - // own (paragraphs, pictures, added content, the objects selected together and their grips), the - // Link tool those on a link, and a Prepare field tool the whole page, as before: comments see - // none of them. + // 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 || top(under.iter().cloned(), hits.tool).is_some(), - QuickTool::EditText => top(under.iter().cloned(), hits.tool).is_some(), + 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 && !press_held { + if !tool_takes_it { let comment = match context { Some(Owner::Comment { index, .. }) => Some(index), _ => None, }; - comments::context(hits.pcx, p, comment, view); + comments::context(hits.pcx, comment, view); } - if let Some(Owner::PrepareField { field, widget }) = context - && !press_held - { + if let Some(Owner::PrepareField { field, widget }) = context { crate::prepare::context(hits.form, field, widget, view); } context diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index be83411072..c689f9fde5 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -119,7 +119,7 @@ pub(crate) enum PagePress { /// tool's. Also every page's while a press that began off the pages is held, and after the /// press was dropped ([`PageRouter::cancel`]) until the button is up. Free, - /// The press began on another page, or between the pages. + /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c0a797fa67..82b340e571 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2047,3 +2047,14 @@ fn cancelling_pointer_gestures_lets_go_of_a_camera_corner() { 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/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index a7e7d6c934..c220f8aea3 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -61,10 +61,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// 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, or on a page -/// without the quadrilateral. +/// 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), though a corner at its +/// edge reaches past it. pub(crate) fn hit(xf: &PageXform, page: usize, view: &DocView, p: Pos2) -> Option { - if view.camera_done.is_some() { + if view.camera_done.is_some() || !xf.rect.contains(p) { return None; } let q = view.camera.filter(|q| q.page == page)?; diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 83ee620ae1..5ba9ca5401 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::{context, context_hit, describe, hit, keys, nudge_keys, page_after_text, page_input}; +pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, 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/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c026604ea..ccb12c7676 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,12 +11,13 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// 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. 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, view: &mut DocView) -> bool { +/// comment's, and one nobody holds is a commenting tool's, unless another tool took it this frame +/// (`taken`: Prepare's selection rectangle, added content), though a 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 free = press == PagePress::Free; + let free = press == PagePress::Free && !taken; 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 owned = match press { @@ -98,11 +99,15 @@ pub(crate) fn hit(cx: &PageCx<'_>, view: &DocView, p: Pos2, on_link: bool) -> Op } 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) }) +} + +/// 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 @@ -113,14 +118,18 @@ pub(crate) fn context_hit(cx: &PageCx<'_>, p: Pos2) -> Option { return None; } let a = cx.comment_at(p)?; - let layer = if a.fill_sign || !is_markup(&a.subtype) { Layer::Placed } else { Layer::Comment }; - Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer, egui::CursorIcon::Default)) + Some(Hit::new(Owner::Comment { index: a.index, part: CommentPart::Body }, layer(a), egui::CursorIcon::Default)) } -/// A right-click at `p` on this page: the page's menu is anchored there (Copy Image), and the -/// comment it is on ([`context_hit`], when it wins there) is selected, or none. -pub(crate) fn context(cx: &PageCx<'_>, p: Pos2, index: Option, view: &mut DocView) { +/// 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))); +} + +/// 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)); } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 6685085614..d412496fcf 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1419,7 +1419,8 @@ fn the_link_tools_hand_stops_at_the_pages_edge() { } /// 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. +/// 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); @@ -1429,6 +1430,7 @@ fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_commen 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 @@ -1544,3 +1546,46 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]); h.run_steps(2); } + +/// 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() { + 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); + 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); + assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + h.drop_at(press + egui::vec2(40.0, 0.0)); + h.run_steps(2); +} + +/// 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); +} diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 281e3d9e18..51999431d9 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1599,3 +1599,21 @@ fn a_right_click_during_a_field_drag_moves_the_dragged_field() { 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"); +} From c1d34fc9eaaf9caaaa3eb1c165870e1a9991e5af Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:11:07 +0100 Subject: [PATCH 19/28] fix(ui): commenting tools leave a frame another tool took; Escape keeps the menu's anchor true Found in review: - **A double-click another tool took finished a polygon.** Round 5 kept commenting tools off a press another tool had taken, but a polygon, polyline or cloud finishes on a double-click or Enter whoever has the press. With Prepare open, a double-click on a field opened its properties and committed the polygon being clicked out. Added text opened for editing did the same with the Edit panel open. The commenting tools now skip such a frame entirely, as `main`'s consumed chain did. The comments' own presses and a click's unselecting still run. - **The menu's anchor after Escape.** While a press Escape cancelled is held, the page's tools are skipped, and a right-click left the page menu on the page right-clicked before. It now anchors where it is, as every other right-click does. - **The guard.** It now lists who asks the page view's hits at a point (`PageHits::at` and `context_at`), so a new place in `page_input.rs` can't test a point again. It takes a file that uses the comments' page context (`PageCx`) for a page module. Its doc of the point tests it doesn't list is now true. - **Docs and visibility.** True docs for `PagePress::Free`, where a press is off the pages, and the camera's hit. `link_ui::screen_rect` is private. Tests: a polygon left unfinished by a double-click on a Prepare field, and a right-click after Escape anchoring the menu. Each was seen failing without its fix. A planted ask of the page view's hits, and a planted unlisted file using `PageCx`, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 8 +++- .../ui-egui/src/canvas/page_pointer/tests.rs | 39 ++++++++++++++++--- crates/ui-egui/src/canvas/page_router.rs | 7 ++-- crates/ui-egui/src/tools/camera_ui.rs | 4 +- crates/ui-egui/src/tools/comments/input.rs | 11 ++++-- crates/ui-egui/src/tools/link_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 30 +++++++++++--- crates/ui-egui/tests/it/prepare.rs | 27 +++++++++++++ 8 files changed, 105 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6cb3211c7c..6775702384 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1498,14 +1498,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 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) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + 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 pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; 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()); diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 1c41858651..2890a63cb1 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -190,9 +190,11 @@ const PAGE_MODULES: [&str; 37] = [ "tools/stamps_ui.rs", ]; -/// What a file that acts on the pages uses: the pointer, a tool's page context, what a press is to -/// a page, or the router's hits and owners. Such a file is a page module. -const PAGE_MODULE_MARKS: [&str; 4] = ["PagePointer", "PageToolContext", "PagePress", "page_router::"]; +/// 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, or the router's hits and owners. 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; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_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"]; @@ -439,8 +441,10 @@ fn hit_test_calls(line: &str) -> Vec { } /// The functions that call a hit test: (file, function, why). Hit tests are found by name (a -/// function named `hit` or `…_hit`, called by path, bare or as a method); a point test by another -/// name has a list of its own (`COMMENT_POINT_TESTERS`). `PageHits::hits` is the one: the hits +/// 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] = [( @@ -449,6 +453,23 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`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::at`, `PageHits::context_at`). +const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".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 place there from testing a point again; a +/// second ask inside one of these is for review to catch. +const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ + ( + "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; 1] = [".comment_at("]; @@ -550,6 +571,14 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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, diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index c689f9fde5..b59aee58be 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -116,8 +116,9 @@ pub(crate) enum PagePress { /// 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 that began off the pages is held, and after the - /// press was dropped ([`PageRouter::cancel`]) until the button is up. + /// 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. Free, /// The press began on another page, or off the pages (between them, or beside one). Elsewhere, @@ -144,7 +145,7 @@ pub(crate) struct Capture { #[derive(Debug, Default)] pub(crate) struct PageRouter { capture: Option, - /// A press is held, and the page it began on (`None`: between the pages). + /// A press is held, and the page it began on (`None`: off the pages, between them or beside one). began: Option>, } diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index c220f8aea3..ce88da25cf 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -62,8 +62,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { /// 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), though a corner at its -/// edge reaches past it. +/// 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; diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index ccb12c7676..8366e36453 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -11,13 +11,14 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// 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, unless another tool took it this frame -/// (`taken`: Prepare's selection rectangle, added content), though a click there still unselects. -/// Returns `true` when the pointer gesture belongs to commenting (text selection must ignore it). +/// 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, though a 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 free = press == PagePress::Free && !taken; + 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 owned = match press { @@ -37,6 +38,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, match cx.tool { // 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), diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 069564afff..041edaf1c7 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -49,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]) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d412496fcf..1fc7c51dd3 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1547,11 +1547,9 @@ fn a_long_press_on_a_comment_selects_it_for_its_menu() { h.run_steps(2); } -/// 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() { +/// 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])); @@ -1563,14 +1561,34 @@ fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() { 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); - assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(1), "the menu is page 2's"); + 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 diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 51999431d9..749eaac69a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -1617,3 +1617,30 @@ fn with_prepare_open_a_shape_tool_drag_draws_no_comment() { 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"); +} From 2a4032b2fc54652a8b0dae1a07002cb0028b64c3 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 18:38:01 +0100 Subject: [PATCH 20/28] fix(ui): a press where two pages touch is one page's; the guard sees PageHits::hits Found in review: - **A press where two pages touch.** With the page gaps hidden, pages can share a row of pixels, and a press there was claimed by both: the first page acted on it, then the second took it over, and the text layer could count it twice. Only a press just begun is claimed now, by the first page that does. This was so on `main` too. - **The guard** listed who asks `PageHits::at` and `context_at`, but not `PageHits::hits`, which they wrap. A new call of it in `page_input.rs` now fails, and the guard's doc says what it still can't see. - **Docs.** `Hit::declines_drag` (a link's drag with the Link tool), `over_mark`, and when the Select tool still unselects under another tool's frame. Tests: two pages claiming one press, seen failing without the fix. A planted call of `PageHits::hits` fails the guard. The router's unit tests now begin a press before claiming it, as `draw_pages` does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 5 +++-- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++----- crates/ui-egui/src/canvas/page_router.rs | 21 ++++++++++++++++++- crates/ui-egui/src/tools/comments/input.rs | 4 ++-- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 647a39d970..39b39e11b0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -236,8 +236,9 @@ fn link_of(owner: Option) -> Option { /// 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`: a Fill & Sign mark takes the press, or would take one under the -/// pointer. +/// 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), or with no button down a mark lies under the pointer, where a +/// press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 2890a63cb1..4936c81367 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -453,13 +453,17 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( "`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::at`, `PageHits::context_at`). -const PAGE_HITS_ASKS: [&str; 2] = [".at(", ".context_at("]; +/// 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; 3] = [".hits(", ".at(", ".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 place there from testing a point again; a -/// second ask inside one of these is for review to catch. -const PAGE_HITS_ASKERS: [(&str, &str, &str); 3] = [ +/// private to `page_input.rs`, so this keeps a new function there from asking by these names; a +/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for +/// review to catch. +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", diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index b59aee58be..0863642aee 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -90,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, } @@ -159,7 +159,12 @@ impl PageRouter { /// 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) { + 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)); } @@ -222,6 +227,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"); @@ -230,6 +236,7 @@ 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()); @@ -257,9 +264,21 @@ mod tests { 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/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 8366e36453..4c4251907b 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -13,8 +13,8 @@ use crate::page_router::{CommentPart, Hit, Owner, PagePress}; /// 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, though a click there still unselects. Returns `true` when the -/// pointer gesture belongs to commenting (text selection must ignore it). +/// 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; From 6756ab0ed46c00807bcb1f7dfcc6f1d0177cecd0 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:14:12 +0100 Subject: [PATCH 21/28] fix(ui): a right-click where two pages touch is one page's; the guard reads split calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found in review: - **A right-click where two pages touch.** With the page gaps hidden, a right-click on the row two pages share ran on both: the first page selected its comment, the second unselected it, and the second page's anchor for the menu stood. A right-click is now the first page's, as a press has been since the last fix. This was so on `main` too. - **The guard** matched one line at a time, so a call rustfmt splits across lines (`view` / `.router` / `.owner(i)`) got past it. A continued line is now read with the one it continues. It also refuses a hit test imported under a name that isn't a hit test's (`use …::hit as under`), which the checks by name would miss. - **Docs.** Links still take a right-click while the primary button is held, as before, and when Escape comes in a press's own frame every page is `Elsewhere`, not `Free`. Tests: a stamp clicked where two pages touch lands on the first, a click pressed on one page and released on the next places nothing, a right-click where two pages touch is the first page's, Prepare keeps its selection on a click that began off the page, and the Note tool starts no note on added text with the Edit panel open. Each was seen failing with its fix, or the rule it pins, reverted. A planted split router read and a planted aliased hit test fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 16 +++- crates/ui-egui/src/canvas/page_input.rs | 5 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 48 ++++++++++- crates/ui-egui/src/canvas/page_router.rs | 3 +- crates/ui-egui/tests/it/pointer.rs | 83 +++++++++++++++++++ 5 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 6775702384..95767e2c06 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1207,6 +1207,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 @@ -1502,7 +1515,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. let input = if view.press_cancelled { - if let Some(p) = pointer.filter(|p| frame.resp.secondary_clicked() && xf.rect.contains(*p)) { + 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 } @@ -1683,6 +1696,7 @@ 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); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 39b39e11b0..0a5d9b24e5 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -109,7 +109,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // goes down, 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 = pointer.filter(|p| resp.secondary_clicked() && xf.rect.contains(*p)).and_then(|p| right_click(&hits, &under, view, p, press_held)); + 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), and with no button down the // Fill & Sign tool's preview hides over one, where a press would pick it up. let over_mark = @@ -332,7 +332,8 @@ fn tool_input( /// ([`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 -/// selections its press acts on are left alone until its release. +/// 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. diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 4936c81367..e0ddc358fe 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -293,7 +293,8 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { } /// 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). +/// the top-level function or `impl` method it is in, line, name). A line rustfmt continued (a +/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. 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(); @@ -301,6 +302,7 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); // The top-level function, or method of an `impl`, that each line is in. 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); @@ -312,7 +314,13 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, if line.trim_start().starts_with("//") { continue; } - for name in find(line) { + 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())), + } + } + for (n, function, text) in lines { + for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } } @@ -320,6 +328,41 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } +/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as +/// under;`), which the checks by name would not see. +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"); + // Each `use` statement, from its first line to its `;`, wherever it is. + 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(format!( + "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ + name it `hit` or `…_hit`." + )); + } + } + statement = None; + } + } + 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()) @@ -615,6 +658,7 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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( diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 0863642aee..e3b88d1a2c 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -118,7 +118,8 @@ pub(crate) enum PagePress { /// 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. + /// 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, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 1fc7c51dd3..205a5b2663 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1607,3 +1607,86 @@ fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() { 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"); +} From 6752b36807159bb0710035e38bbd7d3a3b40856d Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 19:40:46 +0100 Subject: [PATCH 22/28] fix(ui): a mark being moved keeps Fill & Sign's presses; the guard sees calls by path Found in review: - **Fill & Sign during a mark's move.** `main` placed no Fill & Sign mark while a comment's move, resize or reshape was live on the page, whoever held the press. That guard was lost: with a move left over (its release never seen), the next click placed a new mark. A live gesture on the page now counts as over a mark again (`comments::moving_on`, which the comments' own dispatch uses too). - **The guard** missed the router's and the page view's hits called by path (`PageRouter::owner(&view.router, i)`), and its doc said the `.at(` it matches was confined to `page_input.rs`. - `measure_ui::Clicks` derived traits nothing uses. Tests: a Fill & Sign click while a mark's move is live places nothing, seen failing without the fix. A planted router read by path fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 13 ++++++----- .../ui-egui/src/canvas/page_pointer/tests.rs | 22 ++++++++++++++----- crates/ui-egui/src/tools/comments.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 10 ++++++--- crates/ui-egui/src/tools/measure_ui.rs | 2 +- crates/ui-egui/tests/it/pointer.rs | 18 +++++++++++++++ 6 files changed, 50 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 0a5d9b24e5..2f74110d7c 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -110,10 +110,11 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut // 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), and with no button down the - // Fill & Sign tool's preview hides over one, where a press would pick it up. - let over_mark = - matches!(tool, QuickTool::Fill(_)) && (matches!(press, PagePress::Mine(_)) || !ptr.any_down && top(under.iter().cloned(), tool).is_some()); + // 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 over one, + // where a press would pick it up. + 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); @@ -237,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// 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), or with no button down a mark lies under the pointer, where a -/// press would pick it up. +/// mark's, or a Prepare field's), a mark is being moved on this page, or with no button down a mark +/// lies under the pointer, where a press would pick it up. fn tool_input( ui: &egui::Ui, view: &mut DocView, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index e0ddc358fe..f9280e9045 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -454,7 +454,16 @@ fn page_modules_claim_the_cursor_and_never_set_it() { } /// What decides, keeps or drops the press the pages hold. -const ROUTER_WRITES: [&str; 4] = ["router.begin(", "router.press(", "router.settle(", "router.cancel("]; +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] = [ @@ -498,12 +507,12 @@ const HIT_TESTERS: [(&str, &str, &str); 1] = [( /// 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; 3] = [".hits(", ".at(", ".context_at("]; +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 by these names; a -/// call by path (`PageHits::at(&hits, …)`) and a second ask inside a listed function are for -/// review to catch. +/// 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"), @@ -528,7 +537,8 @@ const COMMENT_POINT_TESTERS: [(&str, &str, &str); 3] = [ ]; /// What reads who holds the press the pages hold. -const ROUTER_READS: [&str; 3] = ["router.press_for(", "router.owner(", "router.keeps_drag("]; +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 diff --git a/crates/ui-egui/src/tools/comments.rs b/crates/ui-egui/src/tools/comments.rs index 5ba9ca5401..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::{anchor, context, context_hit, describe, 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/input.rs b/crates/ui-egui/src/tools/comments/input.rs index 4c4251907b..ac3c2a6878 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -31,9 +31,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.gesture = None; return true; } - // A comment being moved, resized or reshaped on this page keeps the frame until it ends. - let moving = - matches!(cv.gesture, Some(Gesture::Move { page, .. } | Gesture::Resize { page, .. } | Gesture::Reshape { page, .. }) if page == cx.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 { // The selected shape's handles, and Fill & Sign's marks, take their own presses. @@ -51,6 +49,12 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, } } +/// 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 diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index a81f01b0b2..2d6336cee6 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -203,7 +203,7 @@ fn snap_to_content( } /// The Measure tools' own clicks this frame: those of a press nobody holds. -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy)] pub(crate) struct Clicks { pub single: bool, pub double: bool, diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 205a5b2663..51444f4517 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1690,3 +1690,21 @@ fn a_note_tool_click_on_added_text_starts_no_note() { 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"); +} From 568ea94f48db05398eb3f3c9d54cbd70a06c49f1 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:09:10 +0100 Subject: [PATCH 23/28] fix(ui): a right-click between the pages anchors no page's menu; the guard finds router readers Found in review: - **A right-click off the pages.** Between the pages or beside one, no page took the right-click, but the page menu still opened over the page view, on the last anchor: Delete page, Paste and Copy Image acted on a page right-clicked long before. This was so on `main` too. The anchor is now cleared on every right-click before the pages set it, so off the pages the menu acts on the current page. - **The guard** took a file as a page module only by marks a router read doesn't need. A file that reads `.router.` is one too, so its router reads are checked. - **Docs.** egui ends a press's drag when any button is released, not when a second goes down. `link_ui::hit` takes the tip from the link it found rather than looking it up again. Tests: a right-click between the pages clears the anchor, seen failing without the fix. A planted router read in an unlisted file fails the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 3 +++ crates/ui-egui/src/canvas/page_input.rs | 6 +++--- crates/ui-egui/src/canvas/page_pointer/tests.rs | 8 ++++---- crates/ui-egui/src/tools/link_ui.rs | 10 ++++++---- crates/ui-egui/tests/it/pointer.rs | 17 +++++++++++++++++ 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 95767e2c06..97e867a090 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1645,6 +1645,9 @@ 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. + view.comments.context_at = None; let mut over_image = false; if tool == QuickTool::EditText { let pointer_pos = ptr.pos; diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 2f74110d7c..49cf70067b 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -105,9 +105,9 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut _ => 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 another button - // goes down, 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. + // 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 diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index f9280e9045..40548a998a 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -191,10 +191,10 @@ const PAGE_MODULES: [&str; 37] = [ ]; /// What a file that acts on the pages uses: the pointer, a tool's or the comments' page context, -/// what a press is to a page, or the router's hits and owners. 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; 5] = ["PagePointer", "PageToolContext", "PageCx", "PagePress", "page_router::"]; +/// 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"]; diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 041edaf1c7..63db81b9f3 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -70,12 +70,14 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem if !xf.rect.contains(p) { return None; } - let index = if tool == QuickTool::Link { - links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index + 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 { - info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?.index + 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 = info.links.iter().find(|l| l.page == page && l.index == index).map(|l| target_label(info, &l.target)); + 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) }) } diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 51444f4517..8364f3a635 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1708,3 +1708,20 @@ fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() { 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); +} From 58bb5ba3d0eb3bfac73a40f8ac95c2c684dd64fd Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 20:37:51 +0100 Subject: [PATCH 24/28] test(ui): the page guard knows any router method, and its readers are tested Found in review: - **The router guard** listed the router's methods by name, so a method added later (`view.router.holds(i)`) would pass every check. A use of the router by a method neither list names now fails outside the functions allowed to write or read it. The comments' point test called by path (`PageCx::comment_at(cx, p)`) is caught too. - **The guard's own readers** were proven only by planted builds. Reading the lines of code (the function each is in, a call rustfmt split) and finding hit tests imported under another name now work on a source string, and a unit test pins them with `hit_test_calls` and `declares`. - **Docs.** Fill & Sign's preview hides wherever a press would be taken (a mark, its handles, a Prepare field), and the hits under the pointer before the tools act are taken on the frames that need them, not "once". Tests: the guard's readers, seen failing with the line joining planted out. A planted router method no list names, and a planted `PageCx::comment_at` by path, each fail the guard. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_input.rs | 12 +- .../ui-egui/src/canvas/page_pointer/tests.rs | 163 ++++++++++++------ 2 files changed, 121 insertions(+), 54 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index 49cf70067b..03bd3e807d 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -96,8 +96,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut } // 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, asked once: for a click's release, a - // right-click, and Fill & Sign's preview. + // 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 { @@ -111,8 +111,8 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut 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 over one, - // where a press would pick it up. + // 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); @@ -238,8 +238,8 @@ fn link_of(owner: Option) -> Option { /// 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 mark -/// lies under the pointer, where a press would pick it up. +/// 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, diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 40548a998a..f3c0952527 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -292,34 +292,40 @@ fn outside_test_modules(source: &str) -> Vec<(usize, &str)> { 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). A line rustfmt continued (a -/// method chain split as `view` / `.router` / `.owner(i)`) is read with the one it continues. +/// 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"); - // The top-level function, or method of an `impl`, that each line is in. - 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())), - } - } - for (n, function, text) in lines { + for (n, function, text) in code_lines(&source) { for name in find(&text) { found.push((file, function.map(str::to_owned), n, name)); } @@ -328,36 +334,45 @@ fn page_module_matches(find: impl Fn(&str) -> Vec) -> Vec<(&'static str, found } -/// Each hit test a page module imports under a name that is not a hit test's (`use …::hit as -/// under;`), which the checks by name would not see. +/// 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"); - // Each `use` statement, from its first line to its `;`, wherever it is. - 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(format!( - "src/{file}: `{name} as {alias}` imports a hit test under another name, which the checks by name miss: \ - name it `hit` or `…_hit`." - )); - } - } - statement = None; + 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 @@ -527,7 +542,7 @@ const PAGE_HITS_ASKERS: [(&str, &str, &str); 5] = [ ]; /// The comments' point test, which their hit tests are built on: which comment lies under a point. -const COMMENT_POINT_TESTS: [&str; 1] = [".comment_at("]; +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] = [ @@ -620,6 +635,28 @@ fn page_modules_act_on_a_press_only_through_the_router() { 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, @@ -697,6 +734,36 @@ fn the_page_guards_read_past_a_test_module_in_the_middle_of_a_file() { 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);")); From b77903012d8433f5aac5d2cd1e6d1e64c5f3c401 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 21:51:52 +0100 Subject: [PATCH 25/28] fix(ui): a camera click on another page moves the quadrilateral there Found in review (CodeRabbit): with the camera tool, a press nobody holds on a page without the quadrilateral moves it there, but a click was located by its press's origin, which egui has cleared by the release frame. So only a drag moved it, and a click did nothing. This was so on `main` too. A click is now located where it came up; `PagePress::Free` already says its press began on this page. Test: `a_camera_click_on_another_page_moves_the_quadrilateral_there`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tools/camera_ui.rs | 5 ++++- crates/ui-egui/tests/it/pointer.rs | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index ce88da25cf..f2e07fab35 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -98,9 +98,12 @@ 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)); } + // 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). + let at = if resp.clicked() { pointer } else { origin }; if (resp.drag_started() || resp.clicked()) && press == PagePress::Free - && origin.is_some_and(|o| xf.rect.contains(o)) + && 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)); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 8364f3a635..2fc79d1f63 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1725,3 +1725,17 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { right_click(&mut h, gap); assert_eq!(h.state().views[0].comments.context_at, None); } + +/// 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)); +} From bdd2ff0ee64cef3b3bc74d6d95c88a29e3ee9a10 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:04:19 +0100 Subject: [PATCH 26/28] fix(ui): a camera click is located where it came up Found in review (CodeRabbit): the last fix located a camera click by the pointer, which can have moved on by the end of the release's frame. A click pressed on page 2 and released on page 1, with the pointer back on page 2 by then, moved the quadrilateral to page 2. `PagePointer` keeps each release's position, so `PagePointer::released` gives where the click came up, and the click is located there. Test: `a_camera_click_released_off_its_page_moves_nothing`, seen failing without the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 9 +++++++++ crates/ui-egui/src/tools/camera_ui.rs | 5 +++-- crates/ui-egui/tests/it/pointer.rs | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index 02cb2208ed..f56614411d 100644 --- a/crates/ui-egui/src/canvas/page_pointer.rs +++ b/crates/ui-egui/src/canvas/page_pointer.rs @@ -78,6 +78,15 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } + + /// Where this frame's last primary release was: where a click came up, which the pointer may + /// have left by the frame's end. + pub(crate) fn released(&self) -> Option { + self.edges.iter().rev().find_map(|e| match e { + Edge::Release(p) => Some(*p), + Edge::Press(_) => None, + }) + } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index f2e07fab35..99b4356a26 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -99,8 +99,9 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe view.camera = Some(CameraQuad::inset(page, xf.pw, xf.ph)); } // 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). - let at = if resp.clicked() { pointer } else { origin }; + // 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)) diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 2fc79d1f63..d01a74cff0 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1739,3 +1739,22 @@ fn a_camera_click_on_another_page_moves_the_quadrilateral_there() { 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)); +} From a0dfbecd4c897d8c0857e50fe905833da71a3586 Mon Sep 17 00:00:00 2001 From: alehel Date: Mon, 2 Nov 2026 22:16:00 +0100 Subject: [PATCH 27/28] fix(ui): a frame's last release is kept past the cap on its edges Found in review (CodeRabbit): `PagePointer` keeps a frame's first eight primary presses and releases, and the camera's click was located at the last release among those. After a stall with more presses and releases than that, the click was located at an earlier release, so the quadrilateral could go to the wrong page. The frame's last release is now read from all its events (`PagePointer::released`), however many came before it. Test: `a_frames_last_release_is_kept_past_the_cap_on_its_edges`, seen failing with the release read from the capped edges. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/page_pointer.rs | 36 ++++++++++++------- .../ui-egui/src/canvas/page_pointer/tests.rs | 14 ++++++++ crates/ui-egui/src/tools/camera_ui.rs | 2 +- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas/page_pointer.rs b/crates/ui-egui/src/canvas/page_pointer.rs index f56614411d..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`. @@ -78,15 +89,6 @@ impl PagePointer { pub(crate) fn pressed(&self) -> Option { first_press(&self.edges) } - - /// Where this frame's last primary release was: where a click came up, which the pointer may - /// have left by the frame's end. - pub(crate) fn released(&self) -> Option { - self.edges.iter().rev().find_map(|e| match e { - Edge::Release(p) => Some(*p), - Edge::Press(_) => None, - }) - } } /// Whether egui gives the drag to a widget that is not the pages' (a panel's tab, a floating panel's @@ -106,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 f3c0952527..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(); diff --git a/crates/ui-egui/src/tools/camera_ui.rs b/crates/ui-egui/src/tools/camera_ui.rs index 99b4356a26..817d77bb9f 100644 --- a/crates/ui-egui/src/tools/camera_ui.rs +++ b/crates/ui-egui/src/tools/camera_ui.rs @@ -101,7 +101,7 @@ pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointe // 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 }; + 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)) From e69d1221ff4bc588d28edc4afe30ff7cfe85191f Mon Sep 17 00:00:00 2001 From: alehel Date: Tue, 3 Nov 2026 14:41:30 +0100 Subject: [PATCH 28/28] fix(ui): a right-click between the pages deselects the comment; Paste keeps to the picture's page Two of CodeRabbit's findings on #5803. A right-click off the pages cleared only the menu's anchor and kept the selected comment, so the menu that opened was that comment's, not the current page's. A right-click on a page's empty part deselects it, so one that no page takes (`PageFrame.right_clicked`) now does the same. A selected Prepare field keeps its menu off the pages, as it does on a page's empty part; the comment there says so. Where pages touch, the first page takes a right-click on the row they share and anchors the menu there, but a picture on the second page under it still opens its menu. Paste from that menu centred the image on page 1's point, which on page 2 is at its foot. The image menu now uses the anchor only on the picture's own page, and otherwise pastes as Ctrl+V does. Tests, each seen failing with its fix reverted: `a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments`, `paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point`. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 24 ++++++++++-- crates/ui-egui/tests/it/pointer.rs | 60 ++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5b22a38956..a1eeeafadb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1662,7 +1662,9 @@ fn draw_pages( // 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. + // 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 { @@ -1734,8 +1736,22 @@ fn draw_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, @@ -1905,7 +1921,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/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index d01a74cff0..50801d903f 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -1726,6 +1726,66 @@ fn a_right_click_between_the_pages_anchors_no_pages_menu() { 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).