test(ui): page modules read the pointer only through PagePointer

A plain #[test] in page_pointer.rs reads the source of the page modules (the per-page
chain in canvas::document_area and the modules it calls) and fails on any raw read of
egui's pointer state: hover_pos, interact_pos, latest_pos and press_origin, Context's
pointer_*_pos shortcuts, and the primary_* and any_* button reads. Its message names the
file and line and points to PagePointer. 24 open PRs add such reads to these modules, and
three of them (#2137, #1709, #2235) merge cleanly on top of the snapshot, so they would
quietly bring back the touch and overlay bugs it fixed. CI's existing cargo test
--workspace runs it on Linux and Windows.

To pass, the snapshot also carries the button state (primary_down and any_down, global as
before), and the page-input button reads that were left use it or the frame's press edge:
drag ends in Prepare, redact, the Link tool, added content, Edit text and objects, the
comment grab check, a text field opening on the press, and the press that makes a page
current. Code in these files that is not page input is listed, each with its reason, in
NOT_PAGE_INPUT: the field, paragraph and added-text editors (Areas of their own), the
signature dialog's Scan tab, Organize Pages, the page size hint, comment key handling, and
four view gates in document_area that run before the pages' widget exists (zoom anchor,
drawing edge scroll, glide interruption, full screen's click to turn the page), now small
named functions. Prepare a form's calculation order overlay (calc_order_ui) is a page module
too, and takes the pages' pointer for its hover. The test also fails
on a list entry that reads no pointer state any more, and on a source file that takes
PagePointer without being listed as a page module.

Seen failing: a raw hover_pos() put back into zoom_snap.rs, and main's raw read put back
into the stamp block of document_area, are reported with their lines, as are a stale list
entry and crop.rs left off the module list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alehel
2026-10-24 16:54:28 +02:00
co-authored by Claude Opus 5.5
parent cc4726f6e6
commit 3ff8222d72
11 changed files with 233 additions and 52 deletions
+2 -3
View File
@@ -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<Pos2>,
) -> Option<String> {
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);
+36 -12
View File
@@ -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<Pos2> {
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<Pos2>, 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
+12 -13
View File
@@ -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<Pos2> {
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<Pos2> {
(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`.
+1 -1
View File
@@ -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
+5 -5
View File
@@ -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 {
+2 -2
View File
@@ -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) {
+1 -1
View File
@@ -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);
+3 -4
View File
@@ -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
+168 -8
View File
@@ -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<Pos2>,
/// This frame's primary presses and releases of a press the pages hold, in order.
pub edges: Vec<Edge>,
/// 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::<Vec<_>>().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"));
}
}
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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);