diff --git a/crates/ui-egui/src/calc_order_ui.rs b/crates/ui-egui/src/calc_order_ui.rs index 894fe17929..b9a9fe9e83 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 16285f8a98..a933c7acc5 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 4215bed8c4..c0668b5bb6 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 013904988a..ed4d0622fb 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 ec803cf6e6..d0888ce134 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 6935eab65a..8182fa668c 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 55728194d0..446de39fc0 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 ed39e4b206..8d7d64e47c 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 6466f2a877..df373c1547 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 34a5566347..9f58f5ea5e 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 4058ae695e..8a48b3f690 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);