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