From cc4726f6e63cfc1c46e8be99e1335adee32e98c2 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:33:16 +0200 Subject: [PATCH 1/6] fix(ui): one page pointer per frame, so taps work and nothing over a page lets it through The page view had no single reading of the pointer. About twenty page modules each read egui's raw state (hover_pos, latest_pos, interact_pos, press_origin) and tested it against an unclipped page rectangle. That state knows nothing of what is drawn over the pages, and hover_pos is None on the frame a finger or a Windows pen lifts: egui-winit sends PointerGone with every touch release, and winit reports Windows pen input as touch. So a tap placed no stamp, followed no link and ticked no check box. A touch drag of a comment never committed, and the next drag anywhere on the page moved the comment. The Stamp crosshair showed on the command palette, on the quick bar and on the chrome over a page scrolled under it, a link's tip floated over the Unlock prompt, and a double-click on a dialog's backdrop selected the word under it. canvas::document_area now reads one PagePointer per frame from the pages' content widget (step 1 of the page pointer router in the cursor-handling review): - pos: interact_pos, which keeps the position through a touch release, only while egui has the pages under the pointer with nothing over them (contains_pointer, and the top layer there is the pages', as the closed #2626 did) or while the pages hold a press. - origin: where the held press began, also on the frame that brings both the press and its release. This replaces the text layer's raw-event fallback (#2100). - edges: the frame's primary presses and releases on the pages, in order. It reaches the page modules through comments::PageCx and as an argument: the stamp blocks, text layer, page press and link and comment hover in canvas.rs; comments and the signature drag preview; forms and Prepare a form; Fill & Sign and the Sign area; crop, marquee zoom, snapshot, camera and measure; redact and the Link tool; added content, Edit text, images and objects. page_covered, a hand-kept list of what covers the pages that missed the Unlock prompt, is gone. The pages' hover tip is click-through, as a tooltip is, so a quick move onto it does not take the pointer off the link under it. Tests: tests/it/support/input.rs sends pointer input in the frames the platform delivers it in (a touch tap and drag, a click in one frame, a double-click). kittest otherwise runs one frame per event, so the suite never saw a touch release. tests/it/pointer.rs holds the review's probes and #2626's layer tests. With the fix reverted, the tap (stamp, link, check box), touch drag, palette, quick bar, chrome, Unlock tip and dialog double-click tests fail, and the mouse-click and one-frame-click controls pass. The tip test fails with the tip interactable, and each page_pointer unit test fails under a mutation of the part it pins. The probes for later steps stay ignored: the link hand and the I-beam over other tools' targets (step 2), paragraph edges (step 3) and Escape mid-drag (step 4). touch_tap_ticks_a_check_box is ignored until the check box focus fix that follows: since #2223 a tap on a check box counts as a click away from its focus. tests/it/comments.rs: the multiline markup hover test (#3541) hovered a point below the window on a page rotated 270 degrees, and its tip showed only because the hover was tested against the page's whole rectangle. Its harness now zooms to 100 %, so every rotation shows the whole page, and the hover helper asserts the point is on screen. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/camera_ui.rs | 6 +- crates/ui-egui/src/canvas.rs | 93 ++-- crates/ui-egui/src/comments.rs | 27 +- crates/ui-egui/src/content_ui.rs | 33 +- crates/ui-egui/src/crop.rs | 6 +- crates/ui-egui/src/edit_text_ui.rs | 27 +- crates/ui-egui/src/fill_sign.rs | 7 +- crates/ui-egui/src/forms_ui.rs | 20 +- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/link_ui.rs | 11 +- crates/ui-egui/src/measure_ui.rs | 2 +- crates/ui-egui/src/object_ui.rs | 32 +- crates/ui-egui/src/page_pointer.rs | 207 +++++++++ crates/ui-egui/src/prepare.rs | 23 +- crates/ui-egui/src/redact_ui.rs | 13 +- crates/ui-egui/src/sign_ui.rs | 14 +- crates/ui-egui/src/signature_drag.rs | 4 +- crates/ui-egui/src/zoom_snap.rs | 6 +- crates/ui-egui/tests/it/comments.rs | 7 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/pointer.rs | 531 +++++++++++++++++++++++ crates/ui-egui/tests/it/support/input.rs | 65 +++ crates/ui-egui/tests/it/support/mod.rs | 5 +- 23 files changed, 1009 insertions(+), 132 deletions(-) create mode 100644 crates/ui-egui/src/page_pointer.rs create mode 100644 crates/ui-egui/tests/it/pointer.rs create mode 100644 crates/ui-egui/tests/it/support/input.rs diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 720ac7c6a..1cacc4e16 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -5,6 +5,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2}; use crate::canvas::{DocView, PageXform, ViewAction}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; pub use pdfcraft_engine::camera_clean::Look; @@ -58,9 +59,8 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 { } /// Corner dragging on one page. A press on a page without the quadrilateral moves it there. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, view: &mut DocView, allowed: bool) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) { + let (pointer, origin) = (ptr.pos, ptr.origin); if !allowed { if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a0e80e0aa..16285f8a9 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,6 +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_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets}; @@ -3164,15 +3165,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut open_signature = false; let mut hover_text: Option<(Pos2, String)> = None; let mut badge_hover: Option<(Pos2, String)> = None; - // A dialog, save prompt or palette covers the page. Hover tips are drawn above those - // windows, so a comment under the pointer would otherwise float over the dialog. - let page_covered = app.dialog.is_some() - || app.close_request.is_some() - || app.palette_open - || app.password_prompt.is_some() - || app.pending_link.is_some() - || app.pending_remote_link.is_some() - || app.updates.open; let mut clicked_link: Option = None; let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; let mut canvas_action: Option = None; @@ -3261,7 +3253,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut best_overlap = -1.0f32; let mut current_overlap = -1.0f32; let mut current_height = f32::INFINITY; - let pointer = ui.input(|i| i.pointer.hover_pos()); + // The pointer as the pages see it: nothing over them, or a press they hold. The page + // modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp); + let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page // gone to earlier that still shows whole beside it. @@ -3421,14 +3416,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); if editing_content { let page_tex = view.pages.get(&i).map(|p| p.tex.id()); - view.move_background.paint(ui, painter, &xf, i, info, &view.content, page_tex); + view.move_background.paint(painter, &xf, i, info, &view.content, page_tex, pointer); } if view.show_page_gaps { painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); } // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; if let QuickTool::Measure(measure_tool) = tool { crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); } @@ -3437,7 +3432,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // every viewing mode); then comments; then text selection. if let QuickTool::Stamp(kind) = tool && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3461,7 +3456,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if let Some(cs) = custom_stamp.as_ref() && allowed - && let Some(p) = ui.input(|inp| inp.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) + && let Some(p) = ptr.within(xf.rect) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.clicked() { @@ -3488,6 +3483,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, ft, @@ -3511,15 +3507,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, i, info, view); + 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, i, view); + crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); } if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, i, view, can_modify); + crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); } - if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, i, info, view, can_crop) { + if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { view.current = i; open_boxes = true; } @@ -3531,7 +3527,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // While a table is being set up, nothing else is drawn or moved. let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, i, info, &form, field_tool, allowed, view); + let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); field_props |= o.properties; field_placed |= o.placed; open_table = open_table.or(o.table); @@ -3539,10 +3535,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else { use crate::forms_ui::FieldPointer; let field = if tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(ui, &xf, i, info, &added, view)) + && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) && !comments::select_grabs(ui, &pcx, view) { - crate::forms_ui::page_input(ui, &resp, &xf, i, info, &form, can_fill, view) + crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) } else { FieldPointer::Away }; @@ -3556,11 +3552,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { 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(ui, &resp, &xf, i, info, over_text, view) + crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, i, info, view); + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, i, doc, view); + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { let generation = doc.edit_generation(); match view.edit_lines.get(&i) { @@ -3592,6 +3588,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui, &resp, &xf, + &ptr, i, info, &added, @@ -3620,11 +3617,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }; // 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(ui, &resp, &xf, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let text_used = + crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); let image_used = crate::edit_text_ui::image_input( ui, &resp, &xf, + &ptr, i, info, &images, @@ -3634,7 +3633,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ); !others_own_pointer && (text_used || image_used) }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, i, info, &doc_links, view); + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); if tool == QuickTool::Select && resp.secondary_clicked() && can_modify @@ -3712,23 +3711,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } else if over_text && cursor_free { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } - let press_here = ui - .input(|inp| { - inp.pointer.press_origin().or_else(|| { - // A press and release can arrive in one pass; egui has already - // cleared press_origin, but the press still starts multi-click selection. - inp.events.iter().find_map(|event| match event { - egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, .. } - if pos.is_finite() => - { - Some(*pos) - } - _ => None, - }) - }) - }) - .is_some_and(|o| r.contains(o)); - let pressed = ui.input(|inp| inp.pointer.primary_pressed()); + // The pages' press, kept when its release came in the same frame (#2100): it + // still starts multi-click selection. + let press_here = ptr.origin.is_some_and(|o| r.contains(o)); + let pressed = ptr.pressed().is_some(); if pressed && press_here && !over_link { let now = ui.input(|inp| inp.time); // The same limits egui uses to count clicks on release. @@ -3753,7 +3739,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // re-drag over text whose bar Copy closed would keep the bar hidden. view.actions_dismissed = None; view.selection_drag = true; - let origin = ui.input(|inp| inp.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); let (ox, oy) = xf.screen_to_view(origin); if view.clicks < 2 { view.selection = if column { @@ -3843,11 +3829,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::redact_ui::after_text(&resp, i, info, view); } if area_tool { - crate::redact_ui::paint(ui, painter, i, view); + crate::redact_ui::paint(painter, i, view, pointer); } comments::paint_page(ui, painter, &pcx, view); if editing_content { - crate::content_ui::paint_page(ui, painter, &xf, i, info, &added, view); + 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); @@ -3856,10 +3842,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { crate::camera_ui::paint(painter, &xf, i, view); } if tool == QuickTool::Link { - crate::link_ui::paint(ui, painter, &xf, i, info, &doc_links, view); + crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view); + crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } @@ -3870,7 +3856,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } } else { - crate::forms_ui::paint_page(ui, painter, &xf, i, info, &form, view); + crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); } // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a @@ -3899,8 +3885,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } } // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. - if !page_covered - && !matches!(tool, QuickTool::Fill(_)) + if !matches!(tool, QuickTool::Fill(_)) && let Some(p) = pointer { for l in info.links.iter().filter(|l| l.page == i) { @@ -4022,7 +4007,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { && navigation_enabled && unobstructed && !single_paging - && let Some(p) = ui.input(|i| i.pointer.latest_pos()) + && let Some(p) = ptr.pos { let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); if dy != 0.0 { @@ -4307,7 +4292,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { view.frame_visible = visible_now.iter().copied().collect(); if let Some((pos, text)) = hover_text { - egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { + // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. + let tip = egui::Area::new(egui::Id::new("canvas-hover")).order(egui::Order::Tooltip).interactable(false); + tip.fixed_pos(pos + vec2(14.0, 16.0)).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_max_width(320.0); ui.label(text); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index b5c016750..4215bed8c 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -17,6 +17,7 @@ use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewS use pdfcraft_render::{Annotation, DocInfo, PageInfo, PageText}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{QuickTool, icons}; @@ -594,6 +595,8 @@ pub enum SortBy { pub(crate) struct PageCx<'a> { pub page: usize, pub xf: &'a PageXform, + /// The pointer as the pages see it this frame. + pub ptr: &'a PagePointer, pub info: &'a DocInfo, pub tool: QuickTool, pub prefs: &'a CommentPrefs, @@ -808,8 +811,7 @@ pub(crate) fn resized(r: Rect, (hx, hy): (i8, i8), d: egui::Vec2, aspect_ratio: /// 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 { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + 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 over_page = pointer.is_some_and(|p| page_rect.contains(p)); @@ -1129,8 +1131,8 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati /// Where a gesture on this page is decided: the press while the button is down, otherwise the /// pointer (egui clears the press origin on release). `None` off the page. fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { - let (pointer, origin, down) = ui.input(|i| (i.pointer.hover_pos(), i.pointer.press_origin(), i.pointer.any_down())); - (if down { origin } else { pointer }).filter(|p| cx.xf.rect.contains(*p)) + let down = ui.input(|i| i.pointer.any_down()); + (if down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. @@ -1320,7 +1322,7 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut /// 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) { let cv = &view.comments; - let pointer = ui.input(|i| i.pointer.hover_pos()); + let pointer = cx.ptr.pos; // A redaction mark shows its fill while the pointer is over it, as Acrobat's does, so the // marked area reads before anything is applied. if cv.gesture.is_none() @@ -1471,7 +1473,7 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => { // The vertices so far, and a rubber band to the pointer. let mut line = pts.clone(); - if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) { + if let Some(p) = cx.ptr.pos { line.push(p); } painter.add(egui::Shape::line(line, stroke)); @@ -1582,7 +1584,17 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, } return None; }; - let cx = PageCx { page, xf: &xf, info, tool: QuickTool::Select, prefs, allowed: true, hidden: false, redact_mark: ([0.89, 0.13, 0.13], 1.0) }; + let cx = PageCx { + page, + xf: &xf, + ptr: &PagePointer::default(), + info, + tool: QuickTool::Select, + prefs, + allowed: true, + hidden: false, + redact_mark: ([0.89, 0.13, 0.13], 1.0), + }; let anchor = cx.to_screen(c.at); let viewport = view.viewport_rect(); let t = Tokens::get(ctx); @@ -2326,6 +2338,7 @@ mod tests { let cx = PageCx { page: 0, xf: &xf, + ptr: &PagePointer::default(), info: &info, tool: QuickTool::Select, prefs: &prefs, diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 2ac624d3f..013904988 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::widgets; @@ -198,17 +199,18 @@ impl MoveBackground { } /// The page without the moving item, then the item's pixels taken from `page_tex` (the page - /// as rendered, item included where it was) at the dragged or applied position. + /// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or + /// applied position. #[allow(clippy::too_many_arguments)] pub fn paint( &self, - ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, page_tex: Option, + pointer: Option, ) { let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return }; if kp != page { @@ -216,7 +218,7 @@ impl MoveBackground { } let r = screen_rect(xf, info, page, from); let to = if let Some((_, grab, start)) = cv.grab { - let Some(p) = ui.input(|i| i.pointer.hover_pos()) else { return }; + let Some(p) = pointer else { return }; dragged(r, grab, p - start, self.picture) } else if let Some(to) = self.settling { let to = screen_rect(xf, info, page, to); @@ -302,14 +304,12 @@ fn dragged(r: Rect, grab: Grab, d: egui::Vec2, keep_aspect: bool) -> Rect { } /// Added text and images keep pointer ownership over a form field while editing content. -pub(crate) fn select_grabs(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { +pub(crate) fn select_grabs(xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], view: &DocView) -> bool { let cv = &view.content; if cv.grab.is_some_and(|(grab_page, _, _)| grab_page == page) { return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())).filter(|p| xf.rect.contains(*p)) else { - return false; - }; + let Some(p) = ptr.within(xf.rect) else { return false }; on_page(added, page).into_iter().any(|(index, item)| { let rect = screen_rect(xf, info, page, item.content.rect()); rect.expand(2.0).contains(p) @@ -324,6 +324,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, added: &[Added], @@ -333,7 +334,7 @@ pub(crate) fn page_input( view: &mut DocView, ) -> bool { let mut consumed = false; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let cv = &mut view.content; if let Some((gp, grab, start)) = cv.grab && gp == page @@ -408,7 +409,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { cv.grab = Some((page, Grab::Corner(left, top), origin)); consumed = true; @@ -450,10 +451,18 @@ pub(crate) fn page_input( consumed || hit.is_some() && resp.is_pointer_button_down_on() } -/// Outlines of added items (hover and selection with handles) and the box being dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, added: &[Added], view: &DocView) { +/// Outlines of added items (hover and selection with handles) and the box being dragged, the +/// pages' `pointer` over them. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + added: &[Added], + view: &DocView, + pointer: Option, +) { let cv = &view.content; - let pointer = ui.input(|i| i.pointer.hover_pos()); for (i, a) in on_page(added, page) { if cv.draft.as_ref().is_some_and(|d| d.page == page && d.index == Some(i)) { continue; diff --git a/crates/ui-egui/src/crop.rs b/crates/ui-egui/src/crop.rs index 1344a30f3..1f1e1aa13 100644 --- a/crates/ui-egui/src/crop.rs +++ b/crates/ui-egui/src/crop.rs @@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; /// A crop rectangle being dragged: (page, start on screen). pub type CropDrag = Option<(usize, Pos2)>; @@ -17,17 +18,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Crop gestures on one page. Returns `Some(true)` to open Set Page Boxes for this page. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, allowed: bool, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed }); } diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index 65c7bd780..ec803cf6e 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -497,6 +498,7 @@ fn crop_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, view: &mut DocView, @@ -506,12 +508,12 @@ fn crop_input( ) -> bool { let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; - let hover_pos = ui.input(|inp| inp.pointer.hover_pos()); + let hover_pos = ptr.pos; let keep_screen = |keep: [f64; 4]| xf.user_rect(info, page, keep.map(|v| v as f32)).intersect(b); // A drag in progress moves a grip; the box stays inside the picture and at least 6 px. if allow_interaction && resp.drag_started() - && let Some(o) = ui.input(|inp| inp.pointer.press_origin()) + && let Some(o) = ptr.origin { let k = keep_screen(mode.keep); let grip = crop_handles(k).iter().position(|h| h.distance(o) < 8.0).or_else(|| k.contains(o).then_some(8)); @@ -664,7 +666,7 @@ fn stretched_picture(painter: &egui::Painter, xf: &PageXform, tex: egui::Texture /// Once a selected corner or the rotate knob is grabbed, text under it cannot take over the gesture. The same /// applies after the pointer leaves the image/page while dragging. -pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, info: &DocInfo, view: &DocView) -> bool { +pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointer, page: usize, info: &DocInfo, view: &DocView) -> bool { let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page) else { return false }; if selection.drag.is_some() { return true; @@ -672,7 +674,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, page: usize, inf let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = ui.input(|i| if i.pointer.primary_down() { i.pointer.press_origin() } else { i.pointer.hover_pos() }); + let p = if ui.input(|i| i.pointer.primary_down()) { ptr.origin } else { ptr.pos }; let knob = rotate_knob(b, ui.clip_rect()).0; p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) } @@ -800,6 +802,7 @@ pub(crate) fn image_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, images: &[pdfcraft_engine::PageImage], @@ -809,7 +812,7 @@ pub(crate) fn image_input( ) -> bool { let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); let painter = ui.painter(); - let hover_pos = ui.input(|i| i.pointer.hover_pos()); + let hover_pos = ptr.pos; let image_contains = |index: usize, point: Pos2| { let (x, y) = xf.screen_to_view(point); let Some(p) = info.pages.get(page) else { return false }; @@ -853,7 +856,7 @@ pub(crate) fn image_input( if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, page, info, view, i, b, allow_interaction); + return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; let handles = |rect: Rect| { @@ -903,10 +906,9 @@ pub(crate) fn image_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); } } - let origin = ui.input(|i| i.pointer.press_origin()); if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin && o.distance(knob) < 8.0 { if let Some(s) = view.image_selection.as_mut() { @@ -915,7 +917,7 @@ pub(crate) fn image_input( } } else if allow_interaction && resp.drag_started() - && let Some(o) = origin + && let Some(o) = ptr.origin { let corner = corners.iter().position(|c| c.distance(o) < 8.0); if corner.is_some() || image_contains(i, o) { @@ -1209,6 +1211,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, doc: &pdfcraft_engine::Document, lines: &[pdfcraft_engine::TextBlock], @@ -1256,7 +1259,7 @@ pub(crate) fn page_input( if let Some(d) = view.block_drag.filter(|d| d.page == page) && let (Some(b), Some(l)) = (boxes.get(d.block).copied(), lines.get(d.block)) { - let p = ui.input(|i| i.pointer.interact_pos()).unwrap_or(d.start); + let p = ptr.pos.unwrap_or(d.start); let dx = p.x - d.start.x; let preview = match d.grip { Grip::Move => b.translate(p - d.start), @@ -1301,13 +1304,13 @@ pub(crate) fn page_input( // quick flick has left the edge by then. if resp.drag_started() && active.is_none() - && let Some(o) = ui.input(|i| i.pointer.press_origin()) + && let Some(o) = ptr.origin && let Some((block, grip)) = grab_at(&boxes, o, upright) { view.block_drag = Some(BlockDrag { page, block, generation: doc.edit_generation(), start: o, grip }); return true; } - let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)) else { return false }; + let Some(p) = ptr.within(xf.rect) else { return false }; let Some((hit, grip)) = grab_at(&boxes, p, upright) else { return false }; if active != Some(hit) { painter.rect_stroke(boxes[hit], CornerRadius::same(2), Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 1684c80be..862b63e72 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature use pdfcraft_render::{DocInfo, PageInfo}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -626,6 +627,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, tool: FillTool, @@ -637,10 +639,7 @@ pub(crate) fn page_input( author: &str, date_text: &Result, ) -> Option { - let pointer = ui.input(|i| i.pointer.hover_pos())?; - if !resp.contains_pointer() || !xf.rect.contains(pointer) { - return None; - } + let pointer = ptr.within(xf.rect)?; let p = info.pages.get(page)?; let at = to_user(xf, info, page, pointer); let saved = match tool { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 0d81e547c..6935eab65 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -9,11 +9,12 @@ use std::collections::HashSet; -use egui::{Color32, CornerRadius, Rect, Stroke, vec2}; +use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -165,14 +166,14 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], allowed: bool, view: &mut DocView, ) -> FieldPointer { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return FieldPointer::Away }; + let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away }; let hit = form.iter().find_map(|f| { f.widgets .iter() @@ -475,9 +476,16 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st picked } -/// Paint focus and hover frames on a page. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); +/// Paint focus and hover frames on a page, the pages' `pointer` over it. +pub(crate) fn paint_page( + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { for f in form { 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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e50145f8c..c1b89dc86 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -101,6 +101,7 @@ pub mod icons; pub mod last_session; pub mod last_view; pub mod motion; +mod page_pointer; mod page_selection_ui; mod pageboxes; mod palette; diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index c714870c5..55728194d 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle}; use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, widgets}; @@ -56,16 +57,18 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] { } /// Link tool input on one page. Returns `true` when the gesture belongs to the tool. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, links: &[LinkItem], view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let lv = &mut view.links; if let Some((dp, start)) = lv.drag && dp == page @@ -104,7 +107,7 @@ pub(crate) fn page_input( return true; } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); lv.drag = Some((page, origin)); return true; } @@ -112,7 +115,7 @@ pub(crate) fn page_input( } /// Outlines of the page's links (selected one bold) and the rectangle being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView) { +pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, links: &[LinkItem], view: &DocView, pointer: Option) { for l in links.iter().filter(|l| l.page == page) { let r = screen_rect(xf, info, page, l.rect); let selected = view.links.selected == Some((page, l.index)); @@ -123,7 +126,7 @@ pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page egui::StrokeKind::Outside, ); } - if let (Some((dp, start)), Some(p)) = (view.links.drag, ui.input(|i| i.pointer.hover_pos())) + if let (Some((dp, start)), Some(p)) = (view.links.drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/measure_ui.rs b/crates/ui-egui/src/measure_ui.rs index 7c675c694..00dad4bd5 100644 --- a/crates/ui-egui/src/measure_ui.rs +++ b/crates/ui-egui/src/measure_ui.rs @@ -196,7 +196,7 @@ pub(crate) fn page_input( let v = info.user_to_view(p[0] as f32, p[1] as f32); xf.norm_to_screen(v[0] / xf.pw, v[1] / xf.ph) }; - let pos = ui.input(|i| i.pointer.hover_pos()).filter(|p| xf.rect.contains(*p)); + let pos = cx.ptr.within(xf.rect); let mut hover = None; let mut snap = None; if let Some(pos) = pos { diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index b68fe352a..ed39e4b20 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); @@ -156,15 +157,22 @@ fn leave_single(view: &mut DocView) { /// Returns true while group selection owns the interaction. Ordinary clicks still reach the /// single-object text/resize tools. A text draft is finished or discarded through its editor. -pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { if view.objects.input_blocked { return true; } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); - let modifier_press = ui.input(|i| i.pointer.primary_pressed()) - && modified(ui) - && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + let modifier_press = + ui.input(|i| i.pointer.primary_pressed()) && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); @@ -176,14 +184,24 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p return false; } let mut selection = std::mem::take(&mut view.objects); - let result = input(&mut selection, ui, resp, xf, page, doc, view); + let result = input(&mut selection, ui, resp, xf, ptr, page, doc, view); view.objects = selection; result } #[allow(clippy::too_many_arguments)] -fn input(s: &mut ObjectSelection, ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, page: usize, doc: &Document, view: &mut DocView) -> bool { - let (pointer, pressed, down) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.primary_pressed(), i.pointer.primary_down())); +fn input( + s: &mut ObjectSelection, + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + doc: &Document, + view: &mut DocView, +) -> bool { + let pointer = ptr.pos; + let (pressed, down) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs new file mode 100644 index 000000000..6466f2a87 --- /dev/null +++ b/crates/ui-egui/src/page_pointer.rs @@ -0,0 +1,207 @@ +//! The pointer as the pages see it, read once per frame from the page view's content widget. +//! +//! egui knows what is drawn over the pages (a dialog, a menu, the palette, the quick bar, the +//! chrome over a page scrolled under it) and keeps a press with the widget it began on until +//! release. egui's raw pointer state knows neither, and its hover position is gone on the frame +//! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and +//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never +//! `hover_pos`, `latest_pos` or `interact_pos`. + +use egui::{Event, PointerButton, Pos2, Rect}; + +/// A primary-button press or release, and where it happened. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Edge { + Press(Pos2), + Release(Pos2), +} + +/// How many of a frame's presses and releases are kept. A frame holds one or two (a click, a +/// double-click in a slow frame), but one after a stall can hold any number. +const MAX_EDGES: usize = 8; + +/// One frame's pointer, as the pages see it. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct PagePointer { + /// Where the pointer is, while the pages are what egui has under it, or while they hold a + /// press (until the frame of its release, past a page's edge included). It is egui's + /// `interact_pos`, which still has the position on the frame a touch lifts. + pub pos: Option, + /// Where the press the pages hold began, also on the frame of its release when the press + /// came in that same frame (egui has forgotten it by then). `None` for a press that began on + /// something else. + pub origin: Option, + /// This frame's primary presses and releases of a press the pages hold, in order. + pub edges: Vec, +} + +impl PagePointer { + /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. + pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { + let (at, origin, edges) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events))); + // 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. + let held = resp.dragged() || resp.interact_pointer_pos().is_some(); + // `contains_pointer` alone also holds a few points under the edge of an Area over the + // pages (egui's interact radius), so the top layer there must be the pages' own. + let layer = ui.layer_id(); + let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let pos = at.filter(|p| held || on_top(p)); + if !held { + return Self { pos, origin: None, edges: Vec::new() }; + } + Self { pos, origin: origin.or_else(|| first_press(&edges)), edges } + } + + /// Where the pointer is, when it is inside `r`. + pub(crate) fn within(&self, r: Rect) -> Option { + self.pos.filter(|p| r.contains(*p)) + } + + /// Where this frame's first primary press was. + pub(crate) fn pressed(&self) -> Option { + first_press(&self.edges) + } +} + +fn first_press(edges: &[Edge]) -> Option { + edges.iter().find_map(|e| match e { + Edge::Press(p) => Some(*p), + Edge::Release(_) => None, + }) +} + +/// The first [`MAX_EDGES`] primary presses and releases among `events`, in order. +fn edges(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, .. } if pos.is_finite() => { + Some(if *pressed { Edge::Press(*pos) } else { Edge::Release(*pos) }) + } + _ => None, + }) + .take(MAX_EDGES) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{Context, Modifiers, Sense, TouchDeviceId, TouchId, TouchPhase, pos2, vec2}; + + /// The pages' widget: x 0..200, y 0..200 of a 400×400 screen. + const PAGES: Rect = Rect { min: Pos2::ZERO, max: Pos2 { x: 200.0, y: 200.0 } }; + /// An Area drawn over the pages' lower right corner. + const OVER: Rect = Rect { min: Pos2 { x: 120.0, y: 120.0 }, max: Pos2 { x: 260.0, y: 260.0 } }; + + fn button(pos: Pos2, button: PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos, button, pressed, modifiers: Modifiers::NONE } + } + + fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } + } + + /// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what + /// the pages see. + fn frame(ctx: &Context, events: Vec, area: bool) -> PagePointer { + let mut seen = PagePointer::default(); + let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); + seen = PagePointer::read(ui, &resp); + if area { + egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { + ui.allocate_exact_size(OVER.size(), Sense::hover()); + }); + } + }); + // An input-only test has no renderer to take the font texture. + output.textures_delta.clear(); + seen + } + + #[test] + fn a_touch_tap_keeps_its_position_on_the_frame_it_lifts() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![], false); + let down = frame(&ctx, vec![touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, PointerButton::Primary, true)], false); + assert_eq!((down.pos, down.origin, down.pressed()), (Some(p), Some(p), Some(p))); + // egui-winit ends a touch with the release and `PointerGone` in one frame. + let up = frame(&ctx, vec![touch(TouchPhase::End, p), button(p, PointerButton::Primary, false), Event::PointerGone], false); + assert_eq!(ctx.input(|i| i.pointer.hover_pos()), None, "egui's hover position is gone"); + assert_eq!(up.pos, Some(p), "the pages still know where the finger lifted"); + assert_eq!(up.edges, [Edge::Release(p)]); + assert_eq!(frame(&ctx, vec![], false), PagePointer::default(), "and forget it the frame after"); + } + + #[test] + fn a_press_and_release_in_one_frame_keep_the_press() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let click = frame(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false); + assert_eq!(ctx.input(|i| i.pointer.press_origin()), None, "egui has forgotten the press"); + assert_eq!(click.origin, Some(p)); + assert_eq!(click.edges, [Edge::Press(p), Edge::Release(p)]); + } + + #[test] + fn something_drawn_over_the_pages_hides_the_pointer() { + let ctx = Context::default(); + // The Area and the widget rects egui hit-tests against come from the frame before. + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let beside = pos2(100.0, 100.0); + assert_eq!(frame(&ctx, vec![Event::PointerMoved(beside)], true).pos, Some(beside)); + // Inside the Area, and just inside its edge where egui's interact radius still finds the + // pages' widget. + for p in [pos2(160.0, 160.0), pos2(122.0, 150.0)] { + assert_eq!(frame(&ctx, vec![Event::PointerMoved(p)], true).pos, None, "{p:?}"); + } + // Off the pages altogether. + assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None); + // A press on the Area is not the pages' press. + let p = pos2(160.0, 160.0); + let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true); + assert_eq!(press, PagePointer::default()); + } + + #[test] + fn a_press_on_the_pages_keeps_the_pointer_until_its_release() { + let ctx = Context::default(); + frame(&ctx, vec![], true); + frame(&ctx, vec![], true); + let start = pos2(40.0, 40.0); + frame(&ctx, vec![Event::PointerMoved(start), button(start, PointerButton::Primary, true)], true); + // Dragged over the Area and off the pages: still theirs. + for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] { + let held = frame(&ctx, vec![Event::PointerMoved(p)], true); + assert_eq!((held.pos, held.origin), (Some(p), Some(start)), "{p:?}"); + } + let end = pos2(300.0, 60.0); + let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true); + assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too"); + assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); + } + + #[test] + fn edges_are_primary_finite_in_order_and_capped() { + let p = pos2(1.0, 2.0); + let q = pos2(3.0, 4.0); + let events = vec![ + button(p, PointerButton::Secondary, true), + button(p, PointerButton::Primary, true), + Event::PointerMoved(q), + button(pos2(f32::NAN, 0.0), PointerButton::Primary, false), + button(q, PointerButton::Primary, false), + ]; + assert_eq!(edges(&events), [Edge::Press(p), Edge::Release(q)]); + let many: Vec = (0..100).map(|i| button(p, PointerButton::Primary, i % 2 == 0)).collect(); + assert_eq!(edges(&many).len(), MAX_EDGES); + assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p)); + } +} diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 5833c3838..34a556634 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; use crate::comments; +use crate::page_pointer::PagePointer; use crate::theme; use crate::widgets; @@ -242,6 +243,7 @@ pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, form: &[FormField], @@ -250,7 +252,7 @@ pub(crate) fn page_input( view: &mut DocView, ) -> Outcome { let mut out = Outcome { consumed: false, properties: false, placed: false, table: None }; - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; let prep = &mut view.prepare; if prep.marquee_before.is_some() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { @@ -337,7 +339,7 @@ pub(crate) fn page_input( if let Some(tool) = tool { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); if resp.drag_started() || resp.clicked() { - let origin = ui.input(|i| i.pointer.press_origin()).filter(|o| xf.rect.contains(*o)).unwrap_or(p); + let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p); prep.grab = Some((page, Grab::Draw(origin), origin)); out.consumed = true; if resp.clicked() { @@ -393,7 +395,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if let Some((_, left, top)) = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(origin) <= 6.0)) { prep.grab = Some((page, Grab::Corner(left, top), origin)); out.consumed = true; @@ -538,10 +540,19 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, } /// Fields with their light-blue fill and name tags, the selection with its handles, and the -/// rectangle being drawn or dragged. -pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, form: &[FormField], view: &DocView) { +/// rectangle being drawn or dragged to the pages' `pointer`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn paint_page( + ui: &egui::Ui, + painter: &egui::Painter, + xf: &PageXform, + page: usize, + info: &DocInfo, + form: &[FormField], + view: &DocView, + pointer: Option, +) { let prep = &view.prepare; - let pointer = ui.input(|i| i.pointer.hover_pos()); let grab = prep.grab.filter(|(gp, ..)| *gp == page); let zoom = xf.zoom().clamp(0.5, 3.0); for f in form { diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 412fc9599..4058ae695 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS, use pdfcraft_render::DocInfo; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::theme::Tokens; use crate::{PdfCraftApp, widgets}; @@ -234,16 +235,18 @@ 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. +#[allow(clippy::too_many_arguments)] pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, + ptr: &PagePointer, page: usize, info: &DocInfo, over_text: impl Fn(Pos2) -> bool, view: &mut DocView, ) -> bool { - let pointer = ui.input(|i| i.pointer.hover_pos().or(i.pointer.interact_pos())); + let pointer = ptr.pos; if let Some((dp, start)) = view.redact_drag && dp == page { @@ -263,7 +266,7 @@ pub(crate) fn page_input( ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } if resp.drag_started() { - let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let origin = ptr.origin.unwrap_or(p); if !over_text(origin) { view.redact_drag = Some((page, origin)); return true; @@ -283,9 +286,9 @@ pub(crate) fn after_text(resp: &egui::Response, page: usize, info: &DocInfo, vie } } -/// The box being drawn. -pub(crate) fn paint(ui: &egui::Ui, painter: &egui::Painter, page: usize, view: &DocView) { - if let (Some((dp, start)), Some(p)) = (view.redact_drag, ui.input(|i| i.pointer.hover_pos())) +/// The box being drawn, to the pages' `pointer`. +pub(crate) fn paint(painter: &egui::Painter, page: usize, view: &DocView, pointer: Option) { + if let (Some((dp, start)), Some(p)) = (view.redact_drag, pointer) && dp == page { let r = Rect::from_two_pos(start, p); diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 25f92530b..6d09115b7 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind}; use crate::fill_sign::SavedSig; +use crate::page_pointer::PagePointer; use crate::theme::{self, Tokens}; use crate::{PdfCraftApp, icons, widgets}; @@ -151,9 +152,16 @@ pub struct SignView { } /// 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, page: usize, info: &pdfcraft_render::DocInfo, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input( + ui: &egui::Ui, + resp: &egui::Response, + xf: &PageXform, + ptr: &PagePointer, + page: usize, + info: &pdfcraft_render::DocInfo, + view: &mut DocView, +) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } diff --git a/crates/ui-egui/src/signature_drag.rs b/crates/ui-egui/src/signature_drag.rs index 788f333e6..e574d68ec 100644 --- a/crates/ui-egui/src/signature_drag.rs +++ b/crates/ui-egui/src/signature_drag.rs @@ -186,8 +186,8 @@ impl SignatureDrag { { return; } - let (pointer, shift) = painter.ctx().input(|i| (i.pointer.hover_pos(), i.modifiers.shift)); - let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), pointer, shift, pending); + let shift = painter.ctx().input(|i| i.modifiers.shift); + let rect = cx.adjusted_rect(a, cv.gesture.as_ref(), cx.ptr.pos, shift, pending); let painter = painter.with_clip_rect(painter.clip_rect().intersect(cx.xf.rect)); cx.xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0); match &layers.front { diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index eb2cc8672..e0315763f 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile}; use crate::canvas::{DocView, PageXform}; +use crate::page_pointer::PagePointer; use crate::{PdfCraftApp, QuickTool}; /// A finished gesture: (page, the rectangle on screen, the same in page view points @@ -12,9 +13,8 @@ use crate::{PdfCraftApp, QuickTool}; 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, page: usize, view: &mut DocView) { - let pointer = ui.input(|i| i.pointer.hover_pos()); - let origin = ui.input(|i| i.pointer.press_origin()); +pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) { + let (pointer, origin) = (ptr.pos, ptr.origin); if pointer.is_some_and(|p| xf.rect.contains(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index a273e3cd6..4980fd66b 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -2172,6 +2172,9 @@ fn markup_hover_harness(pdf: Vec, view_rotation: u16) -> Harness<'static, Pd app.open_bytes("markup.pdf", None, pdf).expect("opens synthetic markup"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); + // Small enough to show the whole page in every rotation: a point hovered below the window + // is not over the page. + app.set_option("zoom", "100").unwrap(); app.views[0].rotation = view_rotation; app }); @@ -2193,7 +2196,9 @@ fn hover_markup_at(h: &mut Harness<'static, PdfCraftApp>, x: f32, y: f32) { pw: page.width, ph: page.height, }; - xf.user_rect(info, 0, [x, y, x, y]).center() + let p = xf.user_rect(info, 0, [x, y, x, y]).center(); + assert!(v.viewport_rect().contains(p), "user point ({x}, {y}) is on screen, at {p:?}"); + p }; h.hover_at(p); h.run_steps(3); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b49642fe0..39807445b 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -57,6 +57,7 @@ mod pageboxes; mod panel_snapshots; mod pdf_links; mod pickers; +mod pointer; mod preferences_layout; mod prepare; mod print; diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs new file mode 100644 index 000000000..73af2373c --- /dev/null +++ b/crates/ui-egui/tests/it/pointer.rs @@ -0,0 +1,531 @@ +//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press +//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a +//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's +//! cursor, tips or presses through. +//! +//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor +//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4). + +use egui_kittest::Harness; +use pdfcraft_engine::StampKind; +use pdfcraft_ui_egui::comments::CommentTool; +use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; + +use crate::support::input::{coalesced_click, double_click, touch_drag, touch_tap}; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +fn stream(content: &str) -> String { + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1) +} + +const CATALOG: &str = "<< /Type /Catalog /Pages 2 0 R >>"; +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; +/// One line of 12 pt Helvetica at x 40, y 300. +const LINE: &str = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + +/// One 300×400 page with a web link at x 50..250, y 300..350. +fn link_pdf() -> Vec { + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + ]) +} + +/// A 300×400 page with a paragraph at y≈300 and a link over its first words. +fn linked_text_pdf() -> Vec { + let text = stream("BT /F1 12 Tf 40 300 Td (Read the guide at example.org before you start.) Tj ET"); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [40 295 160 312] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with a line of text at y≈300 and a rectangle comment over it. +fn comment_on_text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[ + CATALOG, + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /Border [0 0 2] /F 4 /T (Reviewer) >>", + text.as_str(), + HELVETICA, + ]) +} + +/// A 300×400 page with one line of text at x 40, y 300. +fn text_pdf() -> Vec { + let text = stream(LINE); + pdf(&[CATALOG, PAGES, "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", text.as_str(), HELVETICA]) +} + +/// A 300×400 page with an unchecked check box `agree` at x 100..120, y 200..220. +fn 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] >>", + "<< /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 >>", + ]) +} + +/// The app with `pdf` open at `zoom` percent and its first frames run. The left panel stays +/// open only where a test places the page relative to the window's chrome. +fn open(pdf: Vec, zoom: &'static str, left_open: bool) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("test.pdf", None, pdf).unwrap(); + if !left_open { + app.set_option("left", "closed").unwrap(); + } + app.set_option("zoom", zoom).unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +fn stamp_tool(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().quick_tool = QuickTool::Stamp(StampKind::DynApproved); + h.run_steps(4); +} + +fn page(h: &Harness<'_, PdfCraftApp>) -> egui::Rect { + h.state().views[0].page_screen_rect(0).expect("page on screen") +} + +/// The screen point at PDF (x, y) on the 300×400 page. +fn at(h: &Harness<'_, PdfCraftApp>, x: f32, y: f32) -> egui::Pos2 { + let r = page(h); + let k = r.width() / 300.0; + r.min + egui::vec2(x * k, (400.0 - y) * k) +} + +fn cursor_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) -> egui::CursorIcon { + h.hover_at(p); + h.run_steps(2); + h.output().platform_output.cursor_icon +} + +/// A mouse click: press and release in frames of their own, as `drag_at` and `drop_at` send them. +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(4); +} + +/// A mouse drag from `from` by `by`, held over two frames on the way. +fn mouse_drag(h: &mut Harness<'_, PdfCraftApp>, from: egui::Pos2, by: egui::Vec2) { + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + h.hover_at(from + by); + h.run_steps(2); + h.drop_at(from + by); + for _ in 0..10 { + h.run_steps(2); + } +} + +fn stamps(h: &Harness<'_, PdfCraftApp>) -> usize { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").count() +} + +fn square(h: &Harness<'_, PdfCraftApp>) -> Option<[f32; 4]> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().info.annotations.iter().find(|a| a.subtype == "Square").map(|a| a.rect) +} + +fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone() +} + +/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from +/// worker threads), and returns the last cursor seen. +fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon { + let mut cursor = egui::CursorIcon::Default; + for _ in 0..50 { + cursor = cursor_at(h, p); + if want(cursor) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + cursor +} + +// --------------------------------------------------------------------------------------------- +// The cursor promises one thing and a press does another. + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn stamp_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let on_link = at(&h, 150.0, 325.0); + let off_link = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link"); + let cursor = cursor_at(&mut h, on_link); + mouse_click(&mut h, on_link); + assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link"); + assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn rectangle_tool_over_a_link_shows_its_crosshair() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle); + h.run_steps(4); + let off_link = at(&h, 150.0, 150.0); + let off = cursor_at(&mut h, off_link); + let on_link = at(&h, 150.0, 325.0); + let on = cursor_at(&mut h, on_link); + assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn edit_text_over_a_linked_word_offers_the_move_arrow() { + let mut h = open(linked_text_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::EditText; + for _ in 0..30 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + // One paragraph, one line: a word outside the link, then a word inside it. + let plain = at(&h, 230.0, 303.0); + let linked = at(&h, 60.0, 303.0); + let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move); + let on = cursor_at(&mut h, linked); + let id = h.state().views[0].id; + let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0)); + let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect; + assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}"); + assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too"); +} + +#[test] +#[ignore = "step 2 of the page pointer router: one cursor writer"] +fn select_tool_over_a_comment_on_text_offers_the_move_arrow() { + let mut h = open(comment_on_text_pdf(), "150", false); + // Inside the rectangle where there is no text, and inside it over a word. + let blank = at(&h, 120.0, 315.0); + let on_text = at(&h, 60.0, 303.0); + let over_blank = cursor_at(&mut h, blank); + let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default); + let before = square(&h).expect("the rectangle"); + let k = page(&h).width() / 300.0; + mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0)); + let after = square(&h).expect("the rectangle"); + assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow"); + assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}"); + assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too"); +} + +/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points +/// left of the edge. Returns the cursor shown at the press point and the line count after. +fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) { + let mut h = open(text_pdf(), "150", false); + assert!(h.state_mut().execute("edit.edit_text")); + for _ in 0..20 { + h.run_steps(2); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let k = page(&h).width() / 300.0; + let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0); + let cursor = cursor_at(&mut h, grip); + h.drag_at(grip); + h.run_steps(1); + for i in 1..=4 { + h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0)); + h.run_steps(1); + } + h.drop_at(grip + egui::vec2(100.0 * k, 0.0)); + for _ in 0..10 { + h.run_steps(2); + } + (cursor, h.state().session.get(id).unwrap().text_lines(0).len()) +} + +#[test] +#[ignore = "step 3 of the page pointer router: one hit test"] +fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() { + let (inside_cursor, inside_lines) = narrow_from_left(1.0); + assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal); + assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph"); + let (outside_cursor, outside_lines) = narrow_from_left(5.0); + assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out"); + assert!(outside_lines > 1, "so a drag from there rewraps it too"); +} + +// --------------------------------------------------------------------------------------------- +// Things drawn over the page keep the page's cursor, tips and presses off. + +#[test] +fn stamp_cursor_stays_off_the_command_palette() { + let mut h = open(link_pdf(), "300", false); + stamp_tool(&mut h); + h.state_mut().palette_open = true; + h.run_steps(4); + let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown"); + // On the palette's frame margin, where none of its widgets sets a cursor, over the page. + let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0); + assert!(page(&h).contains(p), "the palette must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + // The page beside the palette still shows the tool. + let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0); + assert!(page(&h).contains(beside)); + assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair); +} + +fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) { + h.hover_at(egui::pos2(800.0, 500.0)); + h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move }); + for _ in 0..10 { + h.run_steps(2); + } +} + +#[test] +fn stamp_cursor_stays_off_the_quick_bar() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + // Bring the page under the quick bar's left end. + wheel(&mut h, egui::vec2(-200.0, 0.0)); + let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown"); + // Inside the bar's frame margin: no button there. + let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0); + assert!(page(&h).contains(p), "the quick bar must lie over the page here"); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default); + let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0); + assert!(page(&h).contains(below)); + assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair); +} + +#[test] +fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { + let mut h = open(link_pdf(), "400", true); + stamp_tool(&mut h); + let top = page(&h).top(); + wheel(&mut h, egui::vec2(0.0, -300.0)); + let page = page(&h); + // Above where the page started (the viewport's top edge, give or take its margin): the + // toolbar, with the page scrolled up under it. Left of the link, which shows the hand. + let x = page.left() + 20.0; + let p = egui::pos2(x, top - 45.0); + assert!(page.contains(p), "the page must extend under the chrome here"); + // The toolbar keeps its own cursor (a button's hand here); the page's is gone. + assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair); +} + +#[test] +fn link_tip_stays_under_the_unlock_prompt() { + let tip_shown = |h: &Harness<'_, PdfCraftApp>| { + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + h.ctx.memory(|m| m.areas().is_visible(&tip)) + }; + let mut h = open(link_pdf(), "300", true); + let page = page(&h); + let k = page.width() / 300.0; + // Near the link's top left corner, clear of the prompt's card in the middle of the window. + let on_link = page.min + egui::vec2(55.0 * k, (400.0 - 345.0) * k); + assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + + let doc = h.state().views[0].id; + h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); + h.hover_at(on_link + egui::vec2(1.0, 0.0)); + for _ in 0..4 { + h.run_steps(2); + } + let card = h.ctx.memory(|m| m.area_rect(egui::Id::new("unlock"))); + assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}"); + assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt"); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); +} + +#[test] +fn the_link_tip_lets_the_pointer_through_to_the_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip"); + // A quick move lands on the tip, drawn beside where the pointer was, and still on the link. + let onto = tip.min + egui::vec2(6.0, 6.0); + assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link"); + h.hover_at(onto); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link"); +} + +#[test] +fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() { + let mut h = open(text_pdf(), "150", false); + let word = at(&h, 50.0, 303.0); + // The I-beam shows the page's text layer has arrived. + assert_eq!(wait_for_cursor(&mut h, word, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page text is live"); + // Control: without a dialog, a double-click there selects the word. + double_click(&mut h, word); + h.run_steps(4); + assert!(h.state().views[0].selected_text().is_some_and(|t| !t.is_empty()), "control: a double-click selects the word"); + // A click on blank page clears it again. + h.run_steps(30); + let p = at(&h, 150.0, 150.0); + mouse_click(&mut h, p); + h.run_steps(30); + assert!(h.state().views[0].selected_text().is_none_or(|t| t.is_empty()), "cleared before the dialog opens"); + h.state_mut().set_option("dialog", "shortcuts").unwrap(); + h.run_steps(6); + assert!(h.state().dialog.is_some(), "a modal dialog is open"); + // On the dialog's backdrop, where the page's first word lies underneath. + double_click(&mut h, word); + h.run_steps(4); + let selected = h.state().views[0].selected_text(); + assert!(selected.as_deref().is_none_or(str::is_empty), "a double-click on a dialog selects nothing on the page under it: {selected:?}"); +} + +// --------------------------------------------------------------------------------------------- +// Taps, touch drags and clicks within one frame. On the frame a touch (or a Windows pen) lifts, +// egui-winit has sent `PointerGone`, so there is no hover position; egui still reports the click. + +#[test] +fn mouse_click_follows_a_link() { + // Control for the tap below. + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + mouse_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click on a link asks to open it"); +} + +#[test] +fn touch_tap_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + touch_tap(&mut h, p); + assert!(h.state().pending_link.is_some(), "a tap on a link asks to open it, as a click does"); +} + +#[test] +fn coalesced_click_follows_a_link() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 150.0, 325.0); + coalesced_click(&mut h, p); + assert!(h.state().pending_link.is_some(), "a click whose press and release share a frame follows the link"); +} + +#[test] +fn touch_tap_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + touch_tap(&mut h, p); + assert_eq!(stamps(&h), 1, "a tap with the stamp tool places the stamp"); +} + +#[test] +fn coalesced_click_places_a_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + coalesced_click(&mut h, p); + assert_eq!(stamps(&h), 1, "a click whose press and release share a frame places the stamp"); +} + +#[test] +fn mouse_click_ticks_a_check_box() { + // Control for the tap below. + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + mouse_click(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a click ticks the box"); +} + +#[test] +#[ignore = "the check box focus (#2223) counts a tap as a click away: fixed by a later commit"] +fn touch_tap_ticks_a_check_box() { + let mut h = open(checkbox_pdf(), "150", false); + let p = at(&h, 110.0, 210.0); + touch_tap(&mut h, p); + assert_eq!(agree(&h), ["Yes"], "a tap ticks the box, as a click does"); +} + +#[test] +fn touch_drag_moves_a_comment_and_leaves_no_gesture_behind() { + let mut h = open(comment_on_text_pdf(), "150", false); + let k = page(&h).width() / 300.0; + let before = square(&h); + // A finger drags the rectangle 30 pt right, from a blank spot inside it. + let from = at(&h, 120.0, 315.0); + touch_drag(&mut h, from, from + egui::vec2(30.0 * k, 0.0)); + let after_touch = square(&h); + assert_ne!(after_touch, before, "the touch drag moves the rectangle"); + // Later, a mouse drag on empty page far below it. + let p = at(&h, 150.0, 100.0); + mouse_drag(&mut h, p, egui::vec2(10.0 * k, 10.0 * k)); + assert_eq!(square(&h), after_touch, "an unrelated drag elsewhere leaves the rectangle where the touch put it"); +} + +// --------------------------------------------------------------------------------------------- +// Ending a gesture. + +#[test] +#[ignore = "step 4 of the page pointer router: press-time capture and cancel"] +fn escape_cancels_a_signature_rectangle() { + let mut h = open(link_pdf(), "150", false); + h.state_mut().quick_tool = QuickTool::SignArea { certify: false }; + h.run_steps(4); + let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0)); + h.hover_at(a); + h.run_steps(1); + h.drag_at(a); + h.run_steps(1); + h.hover_at(b); + h.run_steps(2); + // Escape while the button is still held. + h.key_press(egui::Key::Escape); + h.run_steps(4); + let s = h.state(); + assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays"); + assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog); +} diff --git a/crates/ui-egui/tests/it/support/input.rs b/crates/ui-egui/tests/it/support/input.rs new file mode 100644 index 000000000..0030c463d --- /dev/null +++ b/crates/ui-egui/tests/it/support/input.rs @@ -0,0 +1,65 @@ +//! Pointer input in the frames the platform delivers it in. +//! +//! `Harness::step` runs one frame for every queued event, each 0.25 s after the last, so input +//! sent through `Harness::event` (or `drag_at` and `drop_at`) never shares a frame. Real input +//! does: egui-winit turns each touch (and on Windows each pen stroke, which winit reports as +//! touch) into a `Touch` event plus pointer events in one frame, and sends `PointerGone` with +//! every release, so `hover_pos()` is `None` on the frame egui reports the tap. A slow frame +//! can also hold a whole click. These helpers push each frame's events into +//! `Harness::input_mut` and step once. +use egui::{Event, Modifiers, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase}; +use egui_kittest::Harness; + +/// Runs one frame with all of `events` in it, in order. +pub fn one_frame(h: &mut Harness<'_, S>, events: impl IntoIterator) { + h.input_mut().events.extend(events); + h.step(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn touch(phase: TouchPhase, pos: Pos2) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(1), phase, pos, force: None } +} + +/// A finger tap at `p`, or a pen tap on Windows: the touch starts (touch, move, press in one +/// frame), and a frame later it ends (touch, release, `PointerGone` in one frame). +pub fn touch_tap(h: &mut Harness<'_, S>, p: Pos2) { + one_frame(h, [touch(TouchPhase::Start, p), Event::PointerMoved(p), button(p, true)]); + h.run_steps(1); + one_frame(h, [touch(TouchPhase::End, p), button(p, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A finger drag from `a` to `b` in six moves, one frame each, ending as a touch does: release +/// and `PointerGone` in one frame. +pub fn touch_drag(h: &mut Harness<'_, S>, a: Pos2, b: Pos2) { + one_frame(h, [touch(TouchPhase::Start, a), Event::PointerMoved(a), button(a, true)]); + for i in 1..=6 { + let p = a + (b - a) * (i as f32 / 6.0); + one_frame(h, [touch(TouchPhase::Move, p), Event::PointerMoved(p)]); + } + one_frame(h, [touch(TouchPhase::End, b), button(b, false), Event::PointerGone]); + h.run_steps(4); +} + +/// A mouse click at `p` whose press and release land in one slow frame (#2100). +pub fn coalesced_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(2); + one_frame(h, [button(p, true), button(p, false)]); + h.run_steps(4); +} + +/// A double-click at `p`: two clicks, each a press and release in one frame, in consecutive +/// frames (0.25 s apart, within egui's 0.3 s double-click delay). +pub fn double_click(h: &mut Harness<'_, S>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + for _ in 0..2 { + one_frame(h, [button(p, true), button(p, false)]); + } + h.run_steps(2); +} diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index 2e1e11ffb..aa96ab20d 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -1,7 +1,10 @@ -//! State-based waits for native preparation, which completes between UI frames. +//! State-based waits for native preparation, which completes between UI frames, and pointer +//! input in the frames the platform delivers it in ([`input`]). use egui_kittest::Harness; use pdfcraft_ui_egui::PdfCraftApp; +pub mod input; + /// Step frames until `ready` holds or 30 seconds pass, and say whether it held. For background work /// (a worker-thread open, page text arriving) that a test then asserts on itself: a frame count is /// not a time, and a busy runner may not have finished the work within one. Sleeps briefly between From 3ff8222d72214d177337d522e852d9b977926d41 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 12:13:10 +0200 Subject: [PATCH 2/6] test(ui): page modules read the pointer only through PagePointer A plain #[test] in page_pointer.rs reads the source of the page modules (the per-page chain in canvas::document_area and the modules it calls) and fails on any raw read of egui's pointer state: hover_pos, interact_pos, latest_pos and press_origin, Context's pointer_*_pos shortcuts, and the primary_* and any_* button reads. Its message names the file and line and points to PagePointer. 24 open PRs add such reads to these modules, and three of them (#2137, #1709, #2235) merge cleanly on top of the snapshot, so they would quietly bring back the touch and overlay bugs it fixed. CI's existing cargo test --workspace runs it on Linux and Windows. To pass, the snapshot also carries the button state (primary_down and any_down, global as before), and the page-input button reads that were left use it or the frame's press edge: drag ends in Prepare, redact, the Link tool, added content, Edit text and objects, the comment grab check, a text field opening on the press, and the press that makes a page current. Code in these files that is not page input is listed, each with its reason, in NOT_PAGE_INPUT: the field, paragraph and added-text editors (Areas of their own), the signature dialog's Scan tab, Organize Pages, the page size hint, comment key handling, and four view gates in document_area that run before the pages' widget exists (zoom anchor, drawing edge scroll, glide interruption, full screen's click to turn the page), now small named functions. Prepare a form's calculation order overlay (calc_order_ui) is a page module too, and takes the pages' pointer for its hover. The test also fails on a list entry that reads no pointer state any more, and on a source file that takes PagePointer without being listed as a page module. Seen failing: a raw hover_pos() put back into zoom_snap.rs, and main's raw read put back into the stamp block of document_area, are reported with their lines, as are a stale list entry and crop.rs left off the module list. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/calc_order_ui.rs | 5 +- crates/ui-egui/src/canvas.rs | 48 ++++++-- crates/ui-egui/src/comments.rs | 25 ++-- crates/ui-egui/src/content_ui.rs | 2 +- crates/ui-egui/src/edit_text_ui.rs | 10 +- crates/ui-egui/src/forms_ui.rs | 4 +- crates/ui-egui/src/link_ui.rs | 2 +- crates/ui-egui/src/object_ui.rs | 7 +- crates/ui-egui/src/page_pointer.rs | 176 ++++++++++++++++++++++++++-- crates/ui-egui/src/prepare.rs | 4 +- crates/ui-egui/src/redact_ui.rs | 2 +- 11 files changed, 233 insertions(+), 52 deletions(-) diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 894fe1792..b9a9fe9e8 100644 --- a/crates/ui-egui/src/calc_order_ui.rs +++ b/crates/ui-egui/src/calc_order_ui.rs @@ -724,10 +724,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// The calculation order on page `page`: the links of the field in focus, with the fields it has /// nothing to do with faded, and the steps of the focus and its linked fields; with nothing in /// focus, the steps of the fields whose place is wrong. A form with many calculations stays -/// readable either way. Returns the field under the pointer. +/// readable either way. Returns the field under the pages' `pointer`. #[allow(clippy::too_many_arguments)] pub(crate) fn paint_page( - ui: &egui::Ui, painter: &egui::Painter, xf: &PageXform, page: usize, @@ -736,6 +735,7 @@ pub(crate) fn paint_page( view: &CalcOrderView, generation: u64, selected: Option<&str>, + pointer: Option, ) -> Option { let listed = view.current(generation)?; // Each field's first widget on this page. @@ -749,7 +749,6 @@ pub(crate) fn paint_page( if on_page.is_empty() { return None; } - let pointer = ui.input(|i| i.pointer.hover_pos()); let hovered = pointer.and_then(|p| on_page.iter().rev().find(|(_, r)| r.contains(p))).map(|(n, _)| (*n).to_owned()); let at: HashMap<&str, Rect> = on_page.iter().copied().collect(); let zoom = xf.zoom().clamp(0.5, 3.0); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16285f8a9..a933c7acc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2836,11 +2836,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Full screen: a click on the document goes forward a page and a shift-click back, as in // Acrobat, unless the click did something else (a link, a word selected). Decided below, // once the pages have had the click. - let present_click = view.presentation.is_some() - && navigation_enabled - && ui.rect_contains_pointer(navigation_rect) - && ui.input(|i| i.pointer.primary_clicked()) - && app.quick_tool == QuickTool::Select; + let present_click = + view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; // Shift as held now, or as the click's own event carried it (a synthetic click). let present_back = present_click && ui.input(|i| { @@ -2851,7 +2848,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { let z = view.zoom * zoom_delta; - match ui.input(|i| i.pointer.hover_pos()) { + match zoom_anchor(ui) { Some(p) => view.zoom_at(z, p), None => view.set_zoom(z), } @@ -2867,7 +2864,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view // that way, so a point beyond what is shown can be reached without letting go of the tool. let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = ui.input(|i| (if i.pointer.any_down() { i.pointer.latest_pos() } else { i.pointer.hover_pos() }, i.stable_dt)); + let (pointer, dt) = drawing_edge_pointer(ui); crate::autoscroll::edge_scroll(pointer, avail, dt) } else { Vec2::ZERO @@ -3007,7 +3004,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; let over_page = ui.rect_contains_pointer(avail); ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))); + let interrupted = glide_interrupted(ui); if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { view.fling.stop(); } @@ -3260,7 +3257,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(avail); + let pressed = unobstructed && ptr.pressed().is_some(); let mut pressed_page = None; if resp.secondary_clicked() { // Set again below when the right-click lands on a link. @@ -3477,7 +3474,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). if let QuickTool::Fill(ft) = tool && allowed - && !comments::fill_grabs(ui, &pcx, view) + && !comments::fill_grabs(&pcx, view) { match crate::fill_sign::page_input( ui, @@ -3536,7 +3533,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { use crate::forms_ui::FieldPointer; let field = if tool == QuickTool::Select && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) - && !comments::select_grabs(ui, &pcx, view) + && !comments::select_grabs(&pcx, view) { crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) } else { @@ -3851,7 +3848,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { } if calc_order { let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = crate::calc_order_ui::paint_page(ui, painter, &xf, i, info, &form, &view.calc_order, generation, selected) { + if let Some(name) = + crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) + { calc_hover = Some(name); } } @@ -4716,6 +4715,31 @@ fn set_layers(app: &mut PdfCraftApp, index: usize, changes: &[(LayerOp, (u32, u1 } } +/// Where a pinch or ⌘-scroll zooms the view about: the pointer, wherever it is over the view. Not +/// page input: it is read before the pages' widget exists, once `rect_contains_pointer` has found +/// the view under the pointer. +fn zoom_anchor(ui: &egui::Ui) -> Option { + ui.input(|i| i.pointer.hover_pos()) +} + +/// The pointer and frame time that scroll the view while a comment is drawn. Not page input: it is +/// read before the pages' widget exists, and the pointer may be past the view's edge. +fn drawing_edge_pointer(ui: &egui::Ui) -> (Option, f32) { + ui.input(|i| (i.pointer.latest_pos(), i.stable_dt)) +} + +/// A press anywhere, or a key, stops the touchpad's glide (#531). +fn glide_interrupted(ui: &egui::Ui) -> bool { + ui.input(|i| i.pointer.any_pressed() || i.events.iter().any(|e| matches!(e, egui::Event::Key { pressed: true, .. }))) +} + +/// Full screen's click on the document, which turns the page: a primary click this frame with the +/// view on top under it in `area`. Not page input: it is read before the pages' widget exists, and +/// the turn waits until the pages have had the click. +fn presentation_click(ui: &egui::Ui, area: Rect) -> bool { + ui.rect_contains_pointer(area) && ui.input(|i| i.pointer.primary_clicked()) +} + /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { p.x >= area.min.x && p.x < area.center().x && p.y > area.center().y && p.y <= area.max.y diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 4215bed8c..c0668b5bb 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -815,8 +815,8 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, let page_rect = cx.xf.rect; let pressed_here = 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(ui, cx, view); - let draw_grab = draw_grabs(ui, cx, view); + let fill_grab = fill_grabs(cx, view); + let draw_grab = draw_grabs(cx, view); 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)) { @@ -1085,20 +1085,20 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, /// moves it and the selected one's handles resize it. Elsewhere the tool places its mark. While /// the button is down the press decides; otherwise the pointer (egui clears the press origin on /// release, so a click is located by the pointer). -pub(crate) fn fill_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - matches!(cx.tool, QuickTool::Fill(_)) && grabs(ui, cx, view, |a| a.fill_sign) +pub(crate) fn fill_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + matches!(cx.tool, QuickTool::Fill(_)) && grabs(cx, view, |a| a.fill_sign) } /// With the Select tool, a comment that would move (not text markup) is picked up before a form /// field under it, so a signature placed over a field can still be dragged off it. -pub(crate) fn select_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { - cx.tool == QuickTool::Select && grabs(ui, cx, view, |a| !is_markup(&a.subtype)) +pub(crate) fn select_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { + cx.tool == QuickTool::Select && grabs(cx, view, |a| !is_markup(&a.subtype)) } /// With a shape tool (rectangle, oval, line, polygon…), the selected comment's resize handles /// are picked up as with the Select tool, so the shape just drawn can be resized straight away /// (#2338). Only the handles: a press anywhere else, the shape itself included, still draws. -pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool { +pub(crate) fn draw_grabs(cx: &PageCx<'_>, view: &DocView) -> bool { let QuickTool::Comment(tool) = cx.tool else { return false }; if !(tool.draws() || tool.clicks_points()) || tool == CommentTool::Eraser || cx.hidden { return false; @@ -1111,12 +1111,12 @@ pub(crate) fn draw_grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView) -> bool if cv.gesture.is_some() { return false; } - press_point(ui, cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) + press_point(cx).is_some_and(|p| on_handle(cx, view, p, |_| true)) } /// Whether the press (or, with no button down, the pointer) is on a `pick` comment or on the /// selected one's resize handles. -fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { +fn grabs(cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotation) -> bool) -> bool { if cx.hidden { return false; } @@ -1124,15 +1124,14 @@ fn grabs(ui: &egui::Ui, cx: &PageCx<'_>, view: &DocView, pick: impl Fn(&Annotati { return true; } - let Some(p) = press_point(ui, cx) else { return false }; + let Some(p) = press_point(cx) else { return false }; on_handle(cx, view, p, &pick) || cx.hit(p).is_some_and(pick) } /// Where a gesture on this page is decided: the press while the button is down, otherwise the /// pointer (egui clears the press origin on release). `None` off the page. -fn press_point(ui: &egui::Ui, cx: &PageCx<'_>) -> Option { - let down = ui.input(|i| i.pointer.any_down()); - (if down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) +fn press_point(cx: &PageCx<'_>) -> Option { + (if cx.ptr.any_down { cx.ptr.origin } else { cx.ptr.pos }).filter(|p| cx.xf.rect.contains(*p)) } /// Whether `p` is on a resize handle of the selected comment, when it is a resizable `pick`. diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 013904988..ed4d0622f 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -340,7 +340,7 @@ pub(crate) fn page_input( && gp == page { consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { cv.grab = None; let end = pointer.unwrap_or(start); if let Some((sp, si)) = cv.selected diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index ec803cf6e..d0888ce13 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -560,7 +560,7 @@ fn crop_input( max.x = max.x.clamp(min.x + min_w, b.max.x); max.y = max.y.clamp(min.y + min_h, b.max.y); mode.keep = user_box(xf, info, page, Rect::from_min_max(min, max)); - if resp.drag_stopped() || !ui.input(|inp| inp.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { mode.drag = None; } } @@ -674,7 +674,7 @@ pub(crate) fn image_owns_pointer(ui: &egui::Ui, xf: &PageXform, ptr: &PagePointe let Some(image) = view.edit_images.get(&page).and_then(|(_, images)| images.get(selection.index)) else { return false }; let Some(rect) = image.visible_rect else { return false }; let b = xf.user_rect(info, page, rect.map(|v| v as f32)); - let p = if ui.input(|i| i.pointer.primary_down()) { ptr.origin } else { ptr.pos }; + let p = if ptr.primary_down { ptr.origin } else { ptr.pos }; let knob = rotate_knob(b, ui.clip_rect()).0; p.is_some_and(|p| [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom(), knob].iter().any(|c| c.distance(p) < 8.0)) } @@ -944,7 +944,7 @@ pub(crate) fn image_input( painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; s.turning = false; @@ -1002,7 +1002,7 @@ pub(crate) fn image_input( painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); handles(preview); } - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { if let Some(s) = view.image_selection.as_mut() { s.drag = None; let norm = |r: Rect| { @@ -1272,7 +1272,7 @@ pub(crate) fn page_input( draw_handles(preview); } ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move }); - if resp.drag_stopped() || !ui.input(|i| i.pointer.any_down()) { + if resp.drag_stopped() || !ptr.any_down { view.block_drag = None; let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview)); let edit = if resizing { diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 6935eab65..8182fa668 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -203,7 +203,7 @@ pub(crate) fn page_input( } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). - let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()); + let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some(); if pressed && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { commit(view, form); view.forms.focus = Some(Focus { press: Some(p), ..open_focus(f, wi) }); @@ -235,7 +235,7 @@ pub(crate) fn page_input( // The press already committed the previous field and opened this one (above), unless // press and release came in one frame. Committing again would re-run a validation that // refused the value on the press. - FormFieldKind::Text if !ui.input(|i| i.pointer.primary_pressed()) => { + FormFieldKind::Text if ptr.pressed().is_none() => { // An editor opened by the press cannot be hit until a frame has laid it out: the // release lands on the page and would take its focus away. if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) { diff --git a/crates/ui-egui/src/link_ui.rs b/crates/ui-egui/src/link_ui.rs index 55728194d..446de39fc 100644 --- a/crates/ui-egui/src/link_ui.rs +++ b/crates/ui-egui/src/link_ui.rs @@ -73,7 +73,7 @@ pub(crate) fn page_input( if let Some((dp, start)) = lv.drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { lv.drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); diff --git a/crates/ui-egui/src/object_ui.rs b/crates/ui-egui/src/object_ui.rs index ed39e4b20..8d7d64e47 100644 --- a/crates/ui-egui/src/object_ui.rs +++ b/crates/ui-egui/src/object_ui.rs @@ -171,11 +171,10 @@ pub(crate) fn page_input( } if view.content.draft.is_some() || view.line_editor.as_ref().is_some_and(|e| e.has_changes()) { let blocked = view.objects.blocked_page == Some(page); - let modifier_press = - ui.input(|i| i.pointer.primary_pressed()) && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); + let modifier_press = ptr.pressed().is_some() && modified(ui) && ptr.pos.is_some_and(|p| xf.rect.contains(p) && ui.clip_rect().contains(p)); if blocked || modifier_press { view.objects.hold_editor = true; - view.objects.blocked_page = ui.input(|i| i.pointer.primary_down()).then_some(page); + view.objects.blocked_page = ptr.primary_down.then_some(page); if modifier_press { view.objects.notice = Some(tl!("Finish or discard the text being edited before selecting several objects.").into()); } @@ -201,7 +200,7 @@ fn input( view: &mut DocView, ) -> bool { let pointer = ptr.pos; - let (pressed, down) = ui.input(|i| (i.pointer.primary_pressed(), i.pointer.primary_down())); + let (pressed, down) = (ptr.pressed().is_some(), ptr.primary_down); let info = &doc.info; if s.page == Some(page) && let Some(mut gesture) = s.gesture diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index 6466f2a87..df373c154 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -4,8 +4,8 @@ //! chrome over a page scrolled under it) and keeps a press with the widget it began on until //! release. egui's raw pointer state knows neither, and its hover position is gone on the frame //! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and -//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never -//! `hover_pos`, `latest_pos` or `interact_pos`. +//! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's +//! pointer positions or button states; a test below keeps it that way. use egui::{Event, PointerButton, Pos2, Rect}; @@ -33,12 +33,17 @@ 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, + /// 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. + pub any_down: bool, } impl PagePointer { /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { - let (at, origin, edges) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin(), edges(&i.events))); + 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())); // 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. @@ -49,9 +54,9 @@ impl PagePointer { let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); let pos = at.filter(|p| held || on_top(p)); if !held { - return Self { pos, origin: None, edges: Vec::new() }; + return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; } - Self { pos, origin: origin.or_else(|| first_press(&edges)), edges } + Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down } } /// Where the pointer is, when it is inside `r`. @@ -164,10 +169,10 @@ mod tests { } // Off the pages altogether. assert_eq!(frame(&ctx, vec![Event::PointerMoved(pos2(300.0, 50.0))], true).pos, None); - // A press on the Area is not the pages' press. + // A press on the Area is not the pages' press, though the button is down all the same. let p = pos2(160.0, 160.0); let press = frame(&ctx, vec![button(p, PointerButton::Primary, true)], true); - assert_eq!(press, PagePointer::default()); + assert_eq!(press, PagePointer { primary_down: true, any_down: true, ..PagePointer::default() }); } #[test] @@ -180,14 +185,24 @@ mod tests { // Dragged over the Area and off the pages: still theirs. for p in [pos2(160.0, 160.0), pos2(300.0, 50.0)] { let held = frame(&ctx, vec![Event::PointerMoved(p)], true); - assert_eq!((held.pos, held.origin), (Some(p), Some(start)), "{p:?}"); + assert_eq!((held.pos, held.origin, held.primary_down), (Some(p), Some(start), true), "{p:?}"); } let end = pos2(300.0, 60.0); let up = frame(&ctx, vec![Event::PointerMoved(end), button(end, PointerButton::Primary, false)], true); assert_eq!((up.pos, up.edges.as_slice()), (Some(end), [Edge::Release(end)].as_slice()), "the release is the pages' too"); + assert!(!up.primary_down && !up.any_down, "and the button is up"); assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); } + #[test] + fn a_secondary_press_is_some_button_down_but_no_primary_edge() { + let ctx = Context::default(); + let p = pos2(50.0, 60.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + let right = frame(&ctx, vec![button(p, PointerButton::Secondary, true)], false); + assert_eq!((right.pos, right.any_down, right.primary_down, right.edges.len()), (Some(p), true, false, 0)); + } + #[test] fn edges_are_primary_finite_in_order_and_capped() { let p = pos2(1.0, 2.0); @@ -204,4 +219,149 @@ mod tests { assert_eq!(edges(&many).len(), MAX_EDGES); assert_eq!(first_press(&[Edge::Release(q), Edge::Press(p), Edge::Press(q)]), Some(p)); } + + /// The page modules: the per-page chain in `canvas::document_area` and what it calls. + const PAGE_MODULES: [&str; 18] = [ + "canvas.rs", + "comments.rs", + "forms_ui.rs", + "prepare.rs", + "fill_sign.rs", + "sign_ui.rs", + "crop.rs", + "zoom_snap.rs", + "camera_ui.rs", + "measure_ui.rs", + "redact_ui.rs", + "link_ui.rs", + "content_ui.rs", + "edit_text_ui.rs", + "object_ui.rs", + "image_pick.rs", + "signature_drag.rs", + "calc_order_ui.rs", + ]; + + /// egui's raw pointer state: where the pointer is, and which buttons are down or went down. + const RAW_READS: [&str; 15] = [ + "hover_pos()", + "interact_pos()", + "latest_pos()", + "press_origin()", + "pointer_hover_pos()", + "pointer_interact_pos()", + "pointer_latest_pos()", + "primary_down()", + "primary_pressed()", + "primary_released()", + "primary_clicked()", + "any_down()", + "any_pressed()", + "any_released()", + "any_click()", + ]; + + /// 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] = [ + ("canvas.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), + ("canvas.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), + ("canvas.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), + ("canvas.rs", "presentation_click", "full screen's click to turn the page, read before the pages' widget exists"), + ("canvas.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), + ("canvas.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), + ("comments.rs", "keys", "keyboard handling after the pages: a drag cancelled with Escape ends once no button is down"), + ("forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), + ("content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), + ("edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), + ("fill_sign.rs", "scan_tab", "the signature dialog's Scan tab"), + ]; + + /// The function a `fn` line declares, laid out as rustfmt does (`pub`, `const` and the like + /// before `fn`). + fn declares(line: &str) -> Option<&str> { + let mut rest = line; + loop { + let before = rest; + for prefix in ["pub(crate) ", "pub(super) ", "pub ", "const ", "async ", "unsafe "] { + rest = rest.strip_prefix(prefix).unwrap_or(rest); + } + if rest == before { + break; + } + } + let rest = rest.strip_prefix("fn ")?; + rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end]) + } + + /// Whether `line` calls `read`, and not a longer name that ends in it. + fn reads(line: &str, read: &str) -> bool { + line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_')) + } + + /// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is + /// drawn over the pages and where a touch lifted, so a raw read in a page module brings back + /// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing. + #[test] + fn page_modules_read_the_pointer_only_through_page_pointer() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut problems = Vec::new(); + let mut needed = vec![false; NOT_PAGE_INPUT.len()]; + for file in PAGE_MODULES { + let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n"); + // Tests drive egui's input themselves. + let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); + // The top-level function, or method of an `impl`, that each line is in. + let (mut function, mut in_impl) = (None, false); + for (n, line) in code.lines().enumerate() { + if line.starts_with("impl") { + (function, in_impl) = (None, true); + } else if line == "}" { + (function, in_impl) = (None, false); + } else if let Some(name) = declares(line).or_else(|| line.strip_prefix(" ").filter(|_| in_impl).and_then(declares)) { + function = Some(name); + } + if line.trim_start().starts_with("//") { + continue; + } + for read in RAW_READS.into_iter().filter(|read| reads(line, read)) { + match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) { + Some(k) => needed[k] = true, + None => problems.push(format!( + "src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \ + (src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \ + lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.", + n + 1 + )), + } + } + } + } + for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) { + if !needed { + problems.push(format!("src/{file}: fn {name} 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. + let mut dirs = vec![src.clone()]; + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + dirs.push(path); + continue; + } + if path.extension().is_none_or(|ext| ext != "rs") { + continue; + } + 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 != "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.")); + } + } + } + assert!(problems.is_empty(), "{}", problems.join("\n")); + } } diff --git a/crates/ui-egui/src/prepare.rs b/crates/ui-egui/src/prepare.rs index 34a556634..9f58f5ea5 100644 --- a/crates/ui-egui/src/prepare.rs +++ b/crates/ui-egui/src/prepare.rs @@ -261,7 +261,7 @@ pub(crate) fn page_input( return out; } if prep.ignore_release { - if !ui.input(|i| i.pointer.primary_down()) { + if !ptr.primary_down { prep.ignore_release = false; } out.consumed = true; @@ -273,7 +273,7 @@ pub(crate) fn page_input( && gp == page { out.consumed = true; - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { prep.grab = None; let end = pointer.unwrap_or(start); match grab { diff --git a/crates/ui-egui/src/redact_ui.rs b/crates/ui-egui/src/redact_ui.rs index 4058ae695..8a48b3f69 100644 --- a/crates/ui-egui/src/redact_ui.rs +++ b/crates/ui-egui/src/redact_ui.rs @@ -250,7 +250,7 @@ pub(crate) fn page_input( if let Some((dp, start)) = view.redact_drag && dp == page { - if resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) { + if resp.drag_stopped() || !ptr.primary_down { view.redact_drag = None; let end = pointer.unwrap_or(start); let r = Rect::from_two_pos(start, end).intersect(xf.rect); From 1bda33975de6342235e98c76a5f6c4356639f40c Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 15 Oct 2026 22:48:18 +0200 Subject: [PATCH 3/6] docs: page modules read PagePointer, never egui's raw pointer state A one-line non-negotiable in CLAUDE.md, next to Layering, so agents and contributors know the rule before the test tells them: page modules read the pointer through PagePointer (crates/ui-egui/src/page_pointer.rs), never hover_pos, interact_pos, latest_pos, press_origin or the button reads. It names the test that enforces it, page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 085e8356f..f51a96d56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - See `plan/README.md` §Clean-room and `plan/adr/0001`. - **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data. - **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. - **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. From 9f2e1e32934acde8ca3637e9ac20e5d4ebee8976 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 17:33:30 +0200 Subject: [PATCH 4/6] fix(ui): a tap on an open field or paragraph editor is not a click away The form field editors and the Edit text paragraph editor are drawn over the pages, outside the page pointer, and close on a click away. With a finger, or a pen on Windows (winit reports it as touch), they closed on taps inside them: - A tap on a combo box opened its list and closed it in the same frame, so a choice could not be picked by touch. - A tap on a list box row picked it, but the list lost focus, so the arrow keys no longer moved the choice. - A tap on a date field's calendar arrows closed the field. - With a paragraph open in Edit text, a tap on a Format text control that only opens a list, such as Line spacing, applied and closed the paragraph, and the list went with it. Two things were wrong. The combo and list boxes judged the click by hover_pos, and the paragraph's "outside the document" by latest_pos. Both are None on the frame egui reports a tap, since egui-winit sends PointerGone with every touch release. They now read interact_pos, which keeps the tap's position on that frame (egui's own clicked_elsewhere does the same). And egui 0.36 reports a lost focus for two frames. The text field editor and the paragraph editor judged their exceptions again on the second frame, when a tap has no position left and a mouse may have moved on. They now judge a loss once, on its own frame (edit_text_ui::lost_focus_once, with a flag kept per editor). A focus taken later in a frame is still seen on the next one, as before. Tests: tests/it/editor_taps.rs taps with support::input::touch_tap, which sends the release and PointerGone in one frame as egui-winit does, and pairs each tap with a mouse click as the control. That click leaves the pointer in the window: kittest's drop_at sends PointerGone a frame later, which the second-frame judging also turned into a click away. With the source reverted, the four tap tests fail at their bug assertion and the controls pass. Reverting one part at a time fails only its own tests: hover_pos in the combo and list check, egui's lost_focus in the field editor (the calendar) or in the paragraph editor, and latest_pos for the paragraph's outside. A tap inside an open text field already worked and has a guard test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/edit_text_ui.rs | 23 ++- crates/ui-egui/src/editing.rs | 1 + crates/ui-egui/src/forms_ui.rs | 18 +- crates/ui-egui/tests/it/editor_taps.rs | 276 +++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + 5 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/tests/it/editor_taps.rs diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d0888ce13..f67398d84 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -246,6 +246,9 @@ pub struct LineEditor { size: f32, /// Take the keyboard focus on the next frame. pub(crate) focus: bool, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`lost_focus_once`]). + had_focus: bool, /// The Format text panel's values, and what the paragraph had (to send only changes). pub look: pdfcraft_engine::AddedText, look0: pdfcraft_engine::AddedText, @@ -1196,6 +1199,7 @@ fn open_editor( max_width, size: (l.size as f32 * scale).clamp(8.0, 72.0), focus: true, + had_focus: false, look: look_of(l), look0: look_of(l), family_chosen: false, @@ -1556,8 +1560,9 @@ fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: /// The inline editor; returns the edit once the text is applied. pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -> Option { - // Clicks outside the document (the Format text panel) keep the paragraph open. - let outside = ctx.input(|i| i.pointer.latest_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); + // Clicks outside the document (the Format text panel) keep the paragraph open. Where the + // click landed is `interact_pos`: `latest_pos` is gone on the frame a tap lifts. + let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(|p| !view.viewport_rect().contains(p)); let page = view.line_editor.as_ref()?.page; let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; @@ -1703,11 +1708,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - r.request_focus(); ed.focus = false; } + let lost = lost_focus_once(&r, &mut ed.had_focus); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); let apply = ui.input(|i| i.key_pressed(egui::Key::Enter) && i.modifiers.command); if esc { done = Some(false); - } else if apply || (r.lost_focus() && !outside && !hold) { + } else if apply || (lost && !outside && !hold) { done = Some(true); } }); @@ -1742,6 +1748,16 @@ pub(crate) fn finished_edit(ed: LineEditor) -> Option { }) } +/// Whether an editor's text box `r` lost the keyboard this frame, given whether it `had` it at +/// the end of the editor's last frame (updated here). egui's `lost_focus` holds for a second +/// frame as well, and an editor that judges a click away by where the click landed must judge it +/// once, on the click's frame: on the next, a tap's position is gone (egui-winit sends +/// `PointerGone` with every touch release) and a mouse may have moved on. +pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { + let has = r.has_focus(); + std::mem::replace(had, has) && !has +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); @@ -1799,6 +1815,7 @@ mod tests { max_width: 400.0, size: block.size as f32, focus: true, + had_focus: false, look: look_of(&block), look0: look_of(&block), family_chosen: false, diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 1e9c0d6b7..edbb56c2c 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -1609,6 +1609,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8182fa668..601061af0 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -43,6 +43,9 @@ pub struct Focus { /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes /// a whole date, and otherwise keeps the month its arrows turned to. pub calendar_text: Option, + /// Whether the text box had the keyboard at the end of the editor's last frame + /// ([`crate::edit_text_ui::lost_focus_once`]). + pub(crate) had_focus: bool, } #[derive(Clone, Debug, Default)] @@ -121,6 +124,7 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + had_focus: false, } } @@ -652,6 +656,10 @@ pub(crate) fn overlay( return None; }; let rect = widget_rect(&xf, info, page, w.rect); + // Whether a click this frame landed outside the field. Where it landed is `interact_pos`: a + // tap's release comes with `PointerGone` (egui-winit sends one with every touch release, and + // winit reports Windows pen input as touch), which clears `hover_pos`. + let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); let zoom = xf.zoom(); let t = Tokens::get(ctx); let mut edit = None; @@ -738,6 +746,7 @@ pub(crate) fn overlay( st.store(ui.ctx(), r.id); } } + let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) }); @@ -745,7 +754,7 @@ pub(crate) fn overlay( close = true; } else if tab { next = Some(!shift); - } else if r.lost_focus() + } else if lost && (!multiline || !enter) && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) { @@ -793,9 +802,7 @@ pub(crate) fn overlay( }); }); }); - let clicked_away = ctx.input(|i| i.pointer.any_click()) - && !resp.response.contains_pointer() - && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); + let clicked_away = !resp.response.contains_pointer() && clicked_outside(); let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { close = true; @@ -829,8 +836,7 @@ pub(crate) fn overlay( edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); } } - let clicked_away = ctx.input(|i| i.pointer.any_click()) && !ctx.input(|i| i.pointer.hover_pos()).is_some_and(|p| rect.contains(p)); - if clicked_away { + if clicked_outside() { close = true; } } diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs new file mode 100644 index 000000000..68d0a7893 --- /dev/null +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -0,0 +1,276 @@ +//! A finger tap, or a pen tap on Windows, on an editor open over the page acts as a mouse click +//! there. The field editors and the Edit text paragraph editor are drawn over the pages, outside +//! the page pointer, and close on a click away. On the frame egui reports a tap there is no +//! hover position (egui-winit sends `PointerGone` with every touch release), so they must judge +//! where the click landed by the click's own position. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; + +use crate::support::input::touch_tap; + +/// A PDF of `objects`, numbered from 1 with the catalog first. +fn pdf(objects: &[&str]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>"; +const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>"; + +/// A 300×400 page with a text field `name`, a combo box `country` and a list box `colour` +/// (Red, Green, Blue) 70 pt tall. +fn form_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [40 340 260 360] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /Ff 131072 /T (country) /Opt [[(ca) (Canada)] [(fr) (France)] [(no) (Norway)]] /Rect [40 290 260 310] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (colour) /Opt [(Red) (Green) (Blue)] /Rect [40 150 200 220] /P 3 0 R /F 4 >>", + HELVETICA, + ]) +} + +/// A 300×400 page with one line of 12 pt text at x 40, y 300. +fn text_pdf() -> Vec { + let line = "BT /F1 12 Tf 40 300 Td (Signed and approved by the committee.) Tj ET"; + let content = format!("<< /Length {} >>\nstream\n{line}\nendstream", line.len() + 1); + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + content.as_str(), + HELVETICA, + ]) +} + +/// The app with `pdf` open at 150 % and its first frames run. +fn open(pdf: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("test.pdf", None, pdf).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + for _ in 0..30 { + h.run_steps(2); + } + h +} + +/// A mouse click: move, press and release in frames of their own, and the pointer stays there. +/// (`drop_at` also sends `PointerGone` a frame later, as if the mouse left the window.) +fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.run_steps(4); +} + +/// Where field `name`'s widget is on screen. +fn field(h: &Harness<'_, 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") +} + +fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// The field whose editor or list is open. +fn focus(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].forms.focus.as_ref().map(|f| f.name.clone()) +} + +/// The Green row of the list box: the second, rows being 11.5 pt under a 2 pt inset. +fn green(h: &Harness<'_, PdfCraftApp>) -> egui::Pos2 { + let r = field(h, "colour"); + egui::pos2(r.center().x, r.min.y + 19.0 * r.height() / 70.0) +} + +// --------------------------------------------------------------------------------------------- +// Form fields. + +#[test] +fn mouse_click_opens_a_combo_box() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a click opens the list of choices"); +} + +#[test] +fn touch_tap_opens_a_combo_box_and_picks_a_choice() { + let mut h = open(form_pdf()); + let p = field(&h, "country").center(); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("country"), "a tap opens the list of choices, as a click does"); + let france = h.get_by_label("France").rect().center(); + touch_tap(&mut h, france); + assert_eq!(value(&h, "country"), ["fr"], "and a tap on a choice picks it"); +} + +#[test] +fn mouse_click_in_a_list_box_keeps_it_focused() { + // Control for the tap below. + let mut h = open(form_pdf()); + let p = green(&h); + mouse_click(&mut h, p); + assert_eq!((value(&h, "colour"), focus(&h).as_deref()), (vec!["Green".to_string()], Some("colour")), "a click picks the row"); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "and the list keeps the keyboard"); +} + +#[test] +fn touch_tap_in_a_list_box_keeps_it_focused() { + let mut h = open(form_pdf()); + let p = green(&h); + touch_tap(&mut h, p); + assert_eq!( + (value(&h, "colour"), focus(&h).as_deref()), + (vec!["Green".to_string()], Some("colour")), + "a tap picks the row and keeps the list focused, as a click does" + ); + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(value(&h, "colour"), ["Blue"], "so the arrow keys go on from it"); +} + +#[test] +fn touch_tap_inside_an_open_text_field_keeps_typing_there() { + // A guard more than a probe: the text editor closes when egui takes its focus away, and + // egui judges that from the tap's own position. + let mut h = open(form_pdf()); + let r = field(&h, "name"); + touch_tap(&mut h, r.center()); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap opens the field for typing"); + touch_tap(&mut h, r.left_center() + egui::vec2(6.0, 0.0)); + assert_eq!(focus(&h).as_deref(), Some("name"), "a tap inside the open field keeps it open"); + h.event(egui::Event::Text("Ada".into())); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "name"), ["Ada"], "and the typing lands in it"); +} + +const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Adds a date field `due` at x 40..160, y 240..260, its calendar under it. +fn add_date_field(h: &mut Harness<'_, PdfCraftApp>) { + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 0, + rect: [40.0, 240.0, 160.0, 260.0], + kind: pdfcraft_engine::NewField::Date, + name: Some("due".into()), + }); + h.run_steps(4); +} + +/// Next month's calendar heading, and its 15th as the date field writes it. +fn next_month(h: &Harness<'_, PdfCraftApp>) -> (String, String) { + let (y, m, _) = h.state().session.today(); + let (ny, nm) = if m == 12 { (y + 1, 1) } else { (y, m + 1) }; + (format!("{} {ny}", MONTHS[(nm - 1) as usize]), format!("{nm:02}/15/{ny}")) +} + +#[test] +fn mouse_click_on_a_date_fields_calendar_keeps_it_open() { + // Control for the tap below. + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + mouse_click(&mut h, p); + let next = h.get_by_label("›").rect().center(); + mouse_click(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a click on the calendar's next month leaves the field open"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + mouse_click(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a click on a day picks it"); +} + +#[test] +fn touch_tap_on_a_date_fields_calendar_keeps_it_open() { + let mut h = open(form_pdf()); + add_date_field(&mut h); + let (heading, picked) = next_month(&h); + let p = field(&h, "due").center(); + touch_tap(&mut h, p); + let next = h.get_by_label("›").rect().center(); + touch_tap(&mut h, next); + assert_eq!(focus(&h).as_deref(), Some("due"), "a tap on the calendar's next month leaves the field open, as a click does"); + h.get_by_label(&heading); + let day = h.get_by_label("15").rect().center(); + touch_tap(&mut h, day); + assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it"); +} + +// --------------------------------------------------------------------------------------------- +// Edit text: a click on the Format text panel keeps the paragraph open. These open the Line +// spacing list: a control that changes the paragraph on the click gives it the keyboard back in +// that same frame, which hides a click away. + +/// Opens the line of `text_pdf` for typing with Edit text. +fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("page on screen"); + let k = r.width() / 300.0; + let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); + mouse_click(h, p); + assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("edit-text-line-input"))), "with the keyboard"); +} + +/// The open paragraph's line spacing, if a paragraph is open. +fn line_spacing(h: &Harness<'_, PdfCraftApp>) -> Option { + h.state().views[0].line_editor.as_ref().map(|e| e.extras.line_spacing) +} + +#[test] +fn mouse_click_on_format_text_keeps_the_paragraph_open() { + // Control for the tap below. + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + mouse_click(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a click opens the Line spacing list and leaves the paragraph open"); + let double = h.get_by_label("2").rect().center(); + mouse_click(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} + +#[test] +fn touch_tap_on_format_text_keeps_the_paragraph_open() { + let mut h = open(text_pdf()); + edit_paragraph(&mut h); + let spacing = h.get_by_value("Auto").rect().center(); + touch_tap(&mut h, spacing); + assert_eq!(line_spacing(&h), Some(0.0), "a tap opens the Line spacing list and leaves the paragraph open, as a click does"); + let double = h.get_by_label("2").rect().center(); + touch_tap(&mut h, double); + assert_eq!(line_spacing(&h), Some(2.0), "to take the spacing picked"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 39807445b..ca96e8bba 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -39,6 +39,7 @@ mod date_format; mod dialog_chrome; mod document_links; mod editing; +mod editor_taps; mod export; mod fill_sign; mod folders; From 80e290acd177e072ee72148e94fbdbdd86422378 Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 13:21:48 +0200 Subject: [PATCH 5/6] fix(ui): a tap on a focused check box or radio button keeps its focus A click on a check box or radio button focuses it, so Tab, Space and the arrows reach it (#2223). The page hands the click to the field, and the field overlay toggles the box in the same frame unless the click landed elsewhere. That check read hover_pos, which is None on the frame egui reports a tap: egui-winit sends PointerGone with every touch release, and winit reports Windows pen input as touch. So a finger or pen tap counted as a click away. The focus closed and the toggle was dropped: a tap left a check box unticked and unfocused, and a tap on a radio button selected nothing. The check now uses the overlay's clicked_outside, which reads interact_pos, as the other field editors do. Tests: touch_tap_ticks_a_check_box runs again. tests/it/editor_taps.rs taps a focused check box and the other button of a focused radio group, each with a mouse click as the control. Each checks that the focus stays and the tap toggles, and for the radio group that the arrows still move within it. With the fix reverted, the three tap tests fail and the controls pass. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/forms_ui.rs | 3 +- crates/ui-egui/tests/it/editor_taps.rs | 73 +++++++++++++++++++++++++- crates/ui-egui/tests/it/pointer.rs | 1 - 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 601061af0..8caa44ced 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -873,8 +873,7 @@ pub(crate) fn overlay( None } }); - let clicked_away = ctx.input(|i| i.pointer.any_click() && !i.pointer.hover_pos().is_some_and(|p| rect.contains(p))); - if esc || clicked_away { + if esc || clicked_outside() { close = true; } else if tab { next = Some(!shift); diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs index 68d0a7893..2f3569661 100644 --- a/crates/ui-egui/tests/it/editor_taps.rs +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -86,10 +86,15 @@ fn mouse_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) { /// Where field `name`'s widget is on screen. fn field(h: &Harness<'_, PdfCraftApp>, name: &str) -> egui::Rect { + widget(h, name, 0) +} + +/// Where widget `w` of field `name` is on screen. +fn widget(h: &Harness<'_, PdfCraftApp>, name: &str, w: usize) -> 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") + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, w).expect("on screen") } fn value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec { @@ -228,6 +233,72 @@ fn touch_tap_on_a_date_fields_calendar_keeps_it_open() { assert_eq!(value(&h, "due"), [picked], "and a tap on a day picks it"); } +/// A 300×400 page with a check box `agree` and a pair of radio buttons `pick`, A and then B to +/// its right, each 20 pt square. A click focuses them, so the keys reach them (#2223). +fn toggles_pdf() -> Vec { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + PAGES, + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [40 300 60 320] /P 3 0 R /F 4 /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>", + "<< /FT /Btn /T (pick) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 240 60 260] /P 3 0 R /F 4 /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 240 120 260] /P 3 0 R /F 4 /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 20 20] /Length 0 >>\nstream\n\nendstream", + ]) +} + +#[test] +fn mouse_click_on_a_focused_check_box_keeps_its_focus() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!((value(&h, "agree"), focus(&h).as_deref()), (vec!["Yes".to_string()], Some("agree")), "a click ticks and focuses the box"); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click on the focused box keeps its focus"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn touch_tap_on_a_focused_check_box_keeps_its_focus() { + let mut h = open(toggles_pdf()); + let p = field(&h, "agree").center(); + mouse_click(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a click focuses the box"); + touch_tap(&mut h, p); + assert_eq!(focus(&h).as_deref(), Some("agree"), "a tap on the focused box keeps its focus, as a click does"); + assert!(value(&h, "agree").is_empty(), "and clears it"); +} + +#[test] +fn mouse_click_on_a_radio_button_keeps_the_group_focused() { + // Control for the tap below. + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["A".to_string()], Some("pick")), "a click selects A and focuses the group"); + mouse_click(&mut h, b); + assert_eq!((value(&h, "pick"), focus(&h).as_deref()), (vec!["B".to_string()], Some("pick")), "a click on B selects it in the focused group"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + +#[test] +fn touch_tap_on_a_radio_button_keeps_the_group_focused() { + let mut h = open(toggles_pdf()); + let (a, b) = (widget(&h, "pick", 0).center(), widget(&h, "pick", 1).center()); + mouse_click(&mut h, a); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a click focuses the group"); + touch_tap(&mut h, b); + assert_eq!(focus(&h).as_deref(), Some("pick"), "a tap on B keeps the group focused, as a click does"); + assert_eq!(value(&h, "pick"), ["B"], "and selects B"); + h.key_press(egui::Key::ArrowLeft); + h.run_steps(3); + assert_eq!(value(&h, "pick"), ["A"], "so the arrows move within the group"); +} + // --------------------------------------------------------------------------------------------- // Edit text: a click on the Format text panel keeps the paragraph open. These open the Line // spacing list: a control that changes the paragraph on the click gives it the keyboard back in diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 73af2373c..9562353ac 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -482,7 +482,6 @@ fn mouse_click_ticks_a_check_box() { } #[test] -#[ignore = "the check box focus (#2223) counts a tap as a click away: fixed by a later commit"] fn touch_tap_ticks_a_check_box() { let mut h = open(checkbox_pdf(), "150", false); let p = at(&h, 110.0, 210.0); From 866de03fd80a984417f51a9f79a7215e737feccb Mon Sep 17 00:00:00 2001 From: alehel Date: Thu, 22 Oct 2026 14:02:17 +0200 Subject: [PATCH 6/6] fix(ui): a dialog covers the pages from the frame it opens The page pointer has the pointer only where egui has the pages on top, and egui knows what is drawn over them from the layers it met in the frame before. Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet. For that frame the pages kept the pointer, and a press that landed then was theirs: a link's tip showed over the dialog, and a click placed a stamp or followed a link under it. main's page_covered had kept the link's tip and click off the page while a dialog was open, from a hand-kept list. PagePointer::read now also takes whether a dialog is open: PdfCraftApp::modal_open, the list the page press and the edge scroll already check, the Unlock prompt included. With one open the pages see no pointer and no press, except a drag they already have, which goes on to its release. The palette is not a dialog and the page beside it keeps its tools, so it is left to egui's layers, from the frame after it opens. Tests: tests/it/pointer.rs hovers a link and opens the Unlock prompt (no tip in that frame), and clicks with the Stamp tool in the frame the prompt opens (no stamp). A page_pointer unit test hides the pointer and a click under a dialog, and keeps a drag the pages have until its release. With the fix reverted, all three fail. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas.rs | 9 +++--- crates/ui-egui/src/page_pointer.rs | 50 ++++++++++++++++++++++++------ crates/ui-egui/tests/it/pointer.rs | 49 ++++++++++++++++++++++++----- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a933c7acc..8a1aa266e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2792,7 +2792,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { None => {} } // No dialog, close prompt or palette over the page: only then does page input count. - let unobstructed = !app.modal_open() && !app.palette_open; + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; let organize_zoom = app.organize_zoom; let view = &mut app.views[index]; if view.organize { @@ -3250,9 +3251,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let mut best_overlap = -1.0f32; let mut current_overlap = -1.0f32; let mut current_height = f32::INFINITY; - // The pointer as the pages see it: nothing over them, or a press they hold. The page - // modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); let pointer = ptr.pos; // A press on a page makes it the current one (as in Acrobat), so a page command with // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page diff --git a/crates/ui-egui/src/page_pointer.rs b/crates/ui-egui/src/page_pointer.rs index df373c154..baf0e2961 100644 --- a/crates/ui-egui/src/page_pointer.rs +++ b/crates/ui-egui/src/page_pointer.rs @@ -6,6 +6,9 @@ //! a touch or a Windows pen lifts: egui-winit sends `PointerGone` with every touch release, and //! winit reports Windows pen input as touch. So page modules read [`PagePointer`], never egui's //! pointer positions or button states; a test below keeps it that way. +//! +//! Dialogs are drawn after the pages, so on the frame one opens egui has not met its layer yet: +//! the reading also takes whether one is open. use egui::{Event, PointerButton, Pos2, Rect}; @@ -24,8 +27,9 @@ const MAX_EDGES: usize = 8; #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct PagePointer { /// Where the pointer is, while the pages are what egui has under it, or while they hold a - /// press (until the frame of its release, past a page's edge included). It is egui's - /// `interact_pos`, which still has the position on the frame a touch lifts. + /// press (until the frame of its release, past a page's edge included). With a dialog open + /// only a drag the pages already have keeps it. It is egui's `interact_pos`, which still has + /// the position on the frame a touch lifts. pub pos: Option, /// Where the press the pages hold began, also on the frame of its release when the press /// came in that same frame (egui has forgotten it by then). `None` for a press that began on @@ -40,18 +44,22 @@ pub(crate) struct PagePointer { } impl PagePointer { - /// The pointer for `resp`, the content widget that holds every page, allocated in `ui`. - pub(crate) fn read(ui: &egui::Ui, resp: &egui::Response) -> Self { + /// 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())); // 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. - let held = resp.dragged() || resp.interact_pointer_pos().is_some(); + // pointer is outside the window. With a dialog open only a drag counts, up to the frame + // it stops: on the frame one opens egui still gives the pages a press that lands under + // it, and its click. + let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some()); // `contains_pointer` alone also holds a few points under the edge of an Area over the - // pages (egui's interact radius), so the top layer there must be the pages' own. + // pages (egui's interact radius), so the top layer there must be the pages' own. A + // dialog covers them from the frame it opens, before egui knows its layer. let layer = ui.layer_id(); - let on_top = |p: &Pos2| resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); + let on_top = |p: &Pos2| !modal && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer); let pos = at.filter(|p| held || on_top(p)); if !held { return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down }; @@ -112,11 +120,16 @@ mod tests { /// Runs one frame of the pages' widget (and the Area over it, with `area`) and returns what /// the pages see. fn frame(ctx: &Context, events: Vec, area: bool) -> PagePointer { + frame_with(ctx, events, area, false) + } + + /// [`frame`], with a dialog open (`modal`) whose layer egui has not met. + fn frame_with(ctx: &Context, events: Vec, area: bool, modal: bool) -> PagePointer { let mut seen = PagePointer::default(); let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events, ..Default::default() }; let mut output = ctx.run_ui(input, |ui| { let (_, resp) = ui.allocate_exact_size(PAGES.size(), Sense::click_and_drag()); - seen = PagePointer::read(ui, &resp); + seen = PagePointer::read(ui, &resp, modal); if area { egui::Area::new(egui::Id::new("over")).fixed_pos(OVER.min).show(ui.ctx(), |ui| { ui.allocate_exact_size(OVER.size(), Sense::hover()); @@ -194,6 +207,25 @@ mod tests { assert_eq!(frame(&ctx, vec![], true).pos, None, "then off the pages, the pointer is not theirs"); } + #[test] + fn a_dialog_hides_the_pointer_and_its_clicks_but_not_a_drag_the_pages_have() { + let ctx = Context::default(); + let p = pos2(50.0, 50.0); + frame(&ctx, vec![Event::PointerMoved(p)], false); + assert_eq!(frame(&ctx, vec![], false).pos, Some(p)); + assert_eq!(frame_with(&ctx, vec![], false, true).pos, None, "no pointer under a dialog, before egui knows its layer too"); + let click = frame_with(&ctx, vec![button(p, PointerButton::Primary, true), button(p, PointerButton::Primary, false)], false, true); + assert_eq!((click.pos, click.origin, click.edges.as_slice()), (None, None, [].as_slice()), "nor a click egui gives the pages then"); + // A drag the pages already have when a dialog opens goes on until its release. + frame(&ctx, vec![button(p, PointerButton::Primary, true)], false); + frame(&ctx, vec![Event::PointerMoved(pos2(80.0, 50.0))], false); + let q = pos2(110.0, 50.0); + let dragging = frame_with(&ctx, vec![Event::PointerMoved(q)], false, true); + assert_eq!((dragging.pos, dragging.origin), (Some(q), Some(p)), "a drag the pages have keeps its pointer under a dialog"); + let up = frame_with(&ctx, vec![button(q, PointerButton::Primary, false)], false, true); + assert_eq!(up.edges.as_slice(), [Edge::Release(q)].as_slice(), "and its release"); + } + #[test] fn a_secondary_press_is_some_button_down_but_no_primary_edge() { let ctx = Context::default(); diff --git a/crates/ui-egui/tests/it/pointer.rs b/crates/ui-egui/tests/it/pointer.rs index 9562353ac..282f53dc0 100644 --- a/crates/ui-egui/tests/it/pointer.rs +++ b/crates/ui-egui/tests/it/pointer.rs @@ -11,7 +11,7 @@ use pdfcraft_engine::StampKind; use pdfcraft_ui_egui::comments::CommentTool; use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt}; -use crate::support::input::{coalesced_click, double_click, touch_drag, touch_tap}; +use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap}; /// A PDF of `objects`, numbered from 1 with the catalog first. fn pdf(objects: &[&str]) -> Vec { @@ -358,12 +358,20 @@ fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() { assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair); } +/// Whether the page's hover tip was drawn in the last frame. +fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool { + let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); + h.ctx.memory(|m| m.areas().is_visible(&tip)) +} + +/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame. +fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) { + let doc = h.state().views[0].id; + h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); +} + #[test] fn link_tip_stays_under_the_unlock_prompt() { - let tip_shown = |h: &Harness<'_, PdfCraftApp>| { - let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas-hover")); - h.ctx.memory(|m| m.areas().is_visible(&tip)) - }; let mut h = open(link_pdf(), "300", true); let page = page(&h); let k = page.width() / 300.0; @@ -373,8 +381,7 @@ fn link_tip_stays_under_the_unlock_prompt() { h.run_steps(2); assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); - let doc = h.state().views[0].id; - h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None }); + open_unlock_prompt(&mut h); h.hover_at(on_link + egui::vec2(1.0, 0.0)); for _ in 0..4 { h.run_steps(2); @@ -399,6 +406,34 @@ fn the_link_tip_lets_the_pointer_through_to_the_link() { assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link"); } +// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet +// and still gives the pages the pointer, and a press there. + +#[test] +fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() { + let mut h = open(link_pdf(), "150", false); + let p = at(&h, 100.0, 340.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand); + h.run_steps(2); + assert!(tip_shown(&h), "the link's tip shows over an uncovered page"); + open_unlock_prompt(&mut h); + h.run_steps(1); + assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens"); +} + +#[test] +fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() { + let mut h = open(link_pdf(), "150", false); + stamp_tool(&mut h); + let p = at(&h, 150.0, 150.0); + assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair); + open_unlock_prompt(&mut h); + let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + one_frame(&mut h, [button(true), button(false)]); + h.run_steps(4); + assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it"); +} + #[test] fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() { let mut h = open(text_pdf(), "150", false);