Files
alehelandClaude Opus 5.5 2929ba854e fix(ui): a click on a link or a form button counts only released over it
A click acted on what its press was on wherever the button came up: a press on a link, a push
button, a signature field or a list box that slipped off it before the release (still a click to
egui, within 6 px) followed the link, ran the button's action, opened signing or picked a list
box's option. On main a click went to what lay under its release. Now it needs both: the
link's or the fields' hit test at the release point must give what the press was on, as a button
pressed and released off itself does nothing. Check boxes and radio buttons already did so, by
their overlay's click-outside test.

New tests a_press_on_a_link_released_off_it_follows_nothing and
a_press_on_a_button_released_off_it_runs_nothing, seen failing with this commit's source
reverted; the check box test shares their slipping_click helper. From CodeRabbit's second review
of #5300.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:14:31 +01:00

960 lines
42 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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 hit test
//! (step 3), and press-time capture with cancel (step 4).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::StampKind;
use pdfcraft_ui_egui::comments::CommentTool;
use pdfcraft_ui_egui::fill_sign::FillTool;
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
/// A PDF of `objects`, numbered from 1 with the catalog first.
fn pdf(objects: &[&str]) -> Vec<u8> {
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<u8> {
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<u8> {
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<u8> {
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 two rectangle comments overlapping at x 100..150, y 200..250: "Bottom
/// note" first in `/Annots`, then "Top note" over it.
fn stacked_comments_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [50 150 150 250] /C [1 0 0] /F 4 /T (Ana) /Contents (Bottom note) >>",
"<< /Type /Annot /Subtype /Square /Rect [100 200 200 300] /C [0 0 1] /F 4 /T (Ben) /Contents (Top note) >>",
])
}
/// A 300×400 page with one line of text at x 40, y 300.
fn text_pdf() -> Vec<u8> {
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<u8> {
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 >>",
])
}
/// A 300×400 page with two unchecked check boxes side by side: `agree` at x 100..120 and
/// `other` at x 120..140, both at y 200..220.
fn two_checkboxes_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 6 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /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 /Annot /Subtype /Widget /FT /Btn /T (other) /V /Off /AS /Off /Rect [120 200 140 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
])
}
/// The check box `agree` of [`checkbox_pdf`], with a highlight drawn over it.
fn highlighted_checkbox_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/ZaDb 0 Tf 0 g) /DR << /Font << /ZaDb 5 0 R >> >> >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [100 200 120 220] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
"<< /Type /Annot /Subtype /Highlight /Rect [95 195 125 225] /QuadPoints [95 225 125 225 95 195 125 195] /C [1 1 0] /F 4 /T (Ana) >>",
])
}
/// A 300×400 page with a push button at x 100..200, y 200..220 whose action opens a web address.
fn button_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /DA (/Helv 0 Tf 0 g) >> >>",
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /Ff 65536 /T (web) /Rect [100 200 200 220] /P 3 0 R /A << /S /URI /URI (https://example.org/) >> >>",
])
}
/// 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<u8>, 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<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone()
}
/// Whether the page's hover tip shows and says `text`.
fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool {
let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).filter(|_| tip_shown(h)) else { return false };
h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center()))
}
/// 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]
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]
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]
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]
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");
}
#[test]
fn a_links_tip_shows_only_with_its_hand() {
let mut h = open(link_pdf(), "150", false);
let on_link = at(&h, 150.0, 325.0);
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, nudge: f32| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
// A new position each time, so the hover is fresh.
let cursor = cursor_at(h, on_link + egui::vec2(nudge, 0.0));
h.run_steps(2);
(cursor, tip_says(h, "https://example.org/"))
};
use egui::CursorIcon::{Crosshair, PointingHand};
assert_eq!(hover(&mut h, QuickTool::Select, 0.0), (PointingHand, true), "the Select tool follows the link, and the tip says where to");
assert_eq!(hover(&mut h, QuickTool::Link, 1.0), (PointingHand, true), "the Link tool edits it");
let stamp = QuickTool::Stamp(StampKind::DynApproved);
assert_eq!(hover(&mut h, stamp, 2.0), (Crosshair, false), "a click places a stamp, so neither hand nor tip offers the link");
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
assert_eq!(hover(&mut h, rectangle, 3.0), (Crosshair, false), "a drag draws a rectangle");
}
/// A drag from plain text onto a link still selects the text: the press decides, not where the
/// pointer has got to once egui calls it a drag.
#[test]
fn a_drag_from_text_onto_a_link_selects_the_text() {
let mut h = open(linked_text_pdf(), "150", false);
// "before you start." lies right of the link, which covers x 40..160.
let (plain, linked) = (at(&h, 200.0, 303.0), at(&h, 100.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
mouse_drag(&mut h, plain, linked - plain);
let selected = h.state().views[0].selected_text().unwrap_or_default();
assert!(selected.contains("example.org"), "the drag selected across the link: {selected:?}");
}
/// Over two comments the tip names the one drawn on top (#3620), and a click should select that
/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in
/// inspection's order, which is by top edge, and selects the one underneath.
#[test]
fn a_click_on_stacked_comments_selects_the_one_drawn_on_top() {
let mut h = open(stacked_comments_pdf(), "150", false);
let overlap = at(&h, 125.0, 225.0);
assert_eq!(cursor_at(&mut h, overlap), egui::CursorIcon::Move);
h.run_steps(2);
assert!(tip_says(&h, "Top note") && !tip_says(&h, "Bottom note"), "the tip names the comment drawn on top");
mouse_click(&mut h, overlap);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "a click selects it too");
}
#[test]
fn a_comments_tip_shows_with_every_tool_beside_its_cursor() {
let mut h = open(comment_on_text_pdf(), "150", false);
// Inside the rectangle comment by "Reviewer": where there is no text, and over a word.
let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0));
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
h.state_mut().quick_tool = tool;
h.run_steps(4);
let cursor = wait_for_cursor(h, p, |c| c == want);
h.run_steps(2);
(cursor, tip_says(h, "Reviewer"))
};
use egui::CursorIcon::{Crosshair, Move, Text};
// A new position each time, so the hover is fresh.
let nudge = |n: f32| blank + egui::vec2(n, 0.0);
assert_eq!(hover(&mut h, QuickTool::Select, blank, Move), (Move, true), "the Select tool picks the comment up");
let stamp = QuickTool::Stamp(StampKind::DynApproved);
assert_eq!(hover(&mut h, stamp, nudge(1.0), Crosshair), (Crosshair, true), "a stamp keeps its crosshair, and the comment still says whose it is");
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
assert_eq!(hover(&mut h, rectangle, nudge(2.0), Crosshair), (Crosshair, true), "so does a drawing tool");
let highlight = QuickTool::Comment(CommentTool::Highlight);
assert_eq!(hover(&mut h, highlight, on_text, Text), (Text, true), "and the highlighter its I-beam");
// Not while drawing freehand, where it would cover the next stroke (#429), nor with Fill & Sign.
let ink = QuickTool::Comment(CommentTool::Ink);
assert_eq!(hover(&mut h, ink, nudge(3.0), Crosshair), (Crosshair, false), "freehand");
let fill = QuickTool::Fill(FillTool::Check);
assert_eq!(hover(&mut h, fill, nudge(4.0), Crosshair), (Crosshair, false), "Fill & Sign");
}
/// 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]
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.
/// Check that the command palette's frame keeps the default cursor over the page while the page beside it
/// still shows the stamp tool.
#[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::unique("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);
}
/// A link beside the open palette shows no tip: tips draw above the palette.
#[test]
fn link_tip_stays_off_while_the_palette_is_open() {
use egui_kittest::kittest::Queryable;
let mut h = open(link_pdf(), "300", false);
h.state_mut().palette_open = true;
h.run_steps(4);
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::unique("palette"))).expect("palette shown");
let beside = [60.0, 100.0, 150.0, 200.0, 240.0]
.into_iter()
.flat_map(|x| [305.0, 325.0, 345.0].map(|y| at(&h, x, y)))
.find(|p| page(&h).contains(*p) && !palette.expand(40.0).contains(*p))
.unwrap_or_else(|| panic!("no point of the link beside the palette {palette:?}, page {:?}", page(&h)));
h.hover_at(beside);
h.run_steps(3);
assert!(h.query_by_label("https://example.org/").is_none(), "no tip beside the palette");
h.state_mut().palette_open = false;
h.hover_at(beside);
h.run_steps(3);
h.get_by_label("https://example.org/");
}
/// A panel's resize edge dragged over a link shows neither the link's tip nor its cursor, and the
/// link answers again once the edge is let go.
#[test]
fn a_dragged_panel_edge_passes_over_a_link_without_its_tip() {
use egui::accesskit::Role;
use egui_kittest::kittest::Queryable;
let mut h = open(link_pdf(), "150", false);
h.state_mut().set_option("panel", "comments").unwrap();
for _ in 0..4 {
h.run_steps(2);
}
let on_link = at(&h, 100.0, 340.0);
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");
// Away from the link, then the side panel's resize edge (the rightmost one) is picked up and
// carried over it.
h.hover_at(at(&h, 150.0, 100.0));
h.run_steps(4);
let tab = h
.get_all_by(|n| n.role() == Role::Splitter && n.label().as_deref() == Some("Resize panels"))
.map(|n| n.rect().center())
.max_by(|a, b| a.x.total_cmp(&b.x))
.expect("the side panel has a resize edge")
// The pane's half of the edge: the page, drawn after the panes, takes the other half.
+ egui::vec2(2.0, 0.0);
h.hover_at(tab);
h.run_steps(1);
h.drag_at(tab);
h.run_steps(1);
for i in 1..=8 {
h.hover_at(tab + (on_link - tab) * (i as f32 / 8.0));
h.run_steps(1);
}
for _ in 0..4 {
h.run_steps(2);
assert!(h.ctx.dragged_id().is_some(), "the edge is still being dragged");
assert!(!tip_shown(&h), "no link tip under a dragged panel edge");
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
}
// Back to the start and let go there, which leaves the width as it was.
for i in 1..=8 {
h.hover_at(on_link + (tab - on_link) * (i as f32 / 8.0));
h.run_steps(1);
}
h.drop_at(tab);
h.run_steps(2);
assert!(h.ctx.dragged_id().is_none());
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "the link answers again");
h.run_steps(2);
assert!(tip_shown(&h), "and shows its tip");
}
/// Send one wheel event over the page and let its scrolling settle.
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,
source: egui::MouseWheelSource::Unknown,
});
for _ in 0..10 {
h.run_steps(2);
}
}
/// Check that the quick bar's frame keeps the default cursor over the page while the page below it still
/// shows the stamp tool.
#[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::unique("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);
}
/// Whether the page's hover tip was drawn in the last frame.
fn tip_shown(h: &Harness<'_, PdfCraftApp>) -> bool {
let tip = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover"));
h.ctx.memory(|m| m.areas().is_visible(&tip))
}
/// Opens the Unlock prompt, a dialog with a backdrop over the whole window, from the next frame.
fn open_unlock_prompt(h: &mut Harness<'_, PdfCraftApp>) {
let doc = h.state().views[0].id;
h.state_mut().unlock_prompt = Some(UnlockPrompt { doc, name: "test.pdf".into(), input: String::new(), error: None, then: None });
}
/// Check that the link tip and pointing cursor disappear under the Unlock prompt.
#[test]
fn link_tip_stays_under_the_unlock_prompt() {
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");
open_unlock_prompt(&mut h);
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::unique("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);
}
/// Check that moving onto a link's floating tip still reports the link's pointing-hand cursor.
#[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::unique("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");
}
// Dialogs are drawn after the pages, so on the frame one opens egui does not know its layer yet
// and still gives the pages the pointer, and a press there.
#[test]
fn the_link_tip_stays_under_a_dialog_on_the_frame_it_opens() {
let mut h = open(link_pdf(), "150", false);
let p = at(&h, 100.0, 340.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
h.run_steps(2);
assert!(tip_shown(&h), "the link's tip shows over an uncovered page");
open_unlock_prompt(&mut h);
h.run_steps(1);
assert!(!tip_shown(&h), "the link's tip must not show over the Unlock prompt on the frame it opens");
}
#[test]
fn a_click_on_the_frame_a_dialog_opens_places_no_stamp() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
let p = at(&h, 150.0, 150.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
open_unlock_prompt(&mut h);
let button = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [button(true), button(false)]);
h.run_steps(4);
assert_eq!(stamps(&h), 0, "a click in the frame the Unlock prompt opens places no stamp under it");
}
#[test]
fn a_double_click_on_a_dialog_backdrop_leaves_page_text_alone() {
let mut h = open(text_pdf(), "150", false);
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");
}
/// The value of the field named `name` on the first view's document.
fn field_value(h: &Harness<'_, PdfCraftApp>, name: &str) -> Vec<String> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone()
}
/// A press belongs to the field it lands on: pressed at the right edge of `agree` and released just
/// over the edge of `other` beside it (a click still, to egui), it ticks neither, as a button
/// pressed and released off itself does nothing. The release used to tick `other`.
#[test]
fn a_press_on_one_check_box_released_over_another_ticks_neither() {
let mut h = open(two_checkboxes_pdf(), "150", false);
let (press, release) = (at(&h, 119.0, 210.0), at(&h, 121.0, 210.0));
slipping_click(&mut h, press, release);
assert_eq!((field_value(&h, "agree"), field_value(&h, "other")), (Vec::<String>::new(), Vec::new()), "neither box is ticked");
}
/// A click whose pointer slips from `press` to `release` before the button comes up (a click
/// still, to egui, while they are close), in frames of their own as [`mouse_click`] sends them.
fn slipping_click(h: &mut Harness<'_, PdfCraftApp>, press: egui::Pos2, release: egui::Pos2) {
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
}
/// A click follows the link its press was on only when released over it: pressed just inside the
/// link's edge and released just past it, it follows nothing, as a button pressed and released off
/// itself does nothing. The press used to be enough.
#[test]
fn a_press_on_a_link_released_off_it_follows_nothing() {
let mut h = open(link_pdf(), "150", false);
// The link ends at x 250.
let press = at(&h, 249.0, 325.0);
slipping_click(&mut h, press, press + egui::vec2(5.0, 0.0));
assert_eq!(h.state().pending_link, None, "nothing asks to open the link");
mouse_click(&mut h, press);
assert!(h.state().pending_link.is_some(), "a click there asks to open it");
}
/// The same for a push button: pressed just inside its edge and released just past it, it runs no
/// action. The press used to be enough.
#[test]
fn a_press_on_a_button_released_off_it_runs_nothing() {
let mut h = open(button_pdf(), "150", false);
// The button ends at x 200.
let press = at(&h, 199.0, 210.0);
slipping_click(&mut h, press, press + egui::vec2(5.0, 0.0));
assert_eq!(h.state().pending_link, None, "the button's action does not run");
mouse_click(&mut h, press);
assert!(h.state().pending_link.is_some(), "a click there runs it");
}
/// Text markup lies on the text, under a form field: a click on a check box under a highlight
/// ticks the box, and the field's hand shows there.
#[test]
fn a_check_box_under_a_highlight_takes_the_click() {
let mut h = open(highlighted_checkbox_pdf(), "150", false);
let p = at(&h, 110.0, 210.0);
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
mouse_click(&mut h, p);
assert_eq!(field_value(&h, "agree"), ["Yes"], "the box is ticked");
assert_eq!(h.state().views[0].comments.selected, None, "and the highlight is not selected");
}
#[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]
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]
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);
}
/// A highlight dragged over the line of [`text_pdf`] at 150 %, from x 45 to x 150, with Escape
/// before the release when `escape`; the comments then on the page.
fn drag_a_highlight(escape: bool) -> usize {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Highlight);
h.run_steps(4);
let (a, b) = (at(&h, 45.0, 303.0), at(&h, 150.0, 303.0));
assert_eq!(wait_for_cursor(&mut h, a, |c| c == egui::CursorIcon::Text), egui::CursorIcon::Text, "the page's text has arrived");
h.drag_at(a);
h.run_steps(1);
h.hover_at(b);
h.run_steps(2);
if escape {
h.key_press(egui::Key::Escape);
h.run_steps(1);
}
h.drop_at(b);
h.run_steps(4);
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.len()
}
/// Escape mid-drag abandons a highlight being drawn over text: the release makes no highlight.
#[test]
fn escape_cancels_a_highlight_being_dragged_over_text() {
assert_eq!(drag_a_highlight(false), 1, "control: the drag highlights the text");
assert_eq!(drag_a_highlight(true), 0, "with Escape before the release, no highlight is made");
}
/// A click on a comment, and in the frame of its release a press beside the page, then a drag
/// from there: the drag is not the comment's, so it does not move it.
#[test]
fn a_drag_from_beside_the_page_right_after_a_click_leaves_the_comment_alone() {
let mut h = open(comment_on_text_pdf(), "150", false);
let on_comment = at(&h, 120.0, 315.0);
let beside = page(&h).left_center() - egui::vec2(20.0, 0.0);
let before = square(&h).expect("the rectangle");
h.hover_at(on_comment);
h.run_steps(1);
h.drag_at(on_comment);
h.run_steps(1);
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [button(on_comment, false), egui::Event::PointerMoved(beside), button(beside, true)]);
h.run_steps(1);
h.hover_at(beside + egui::vec2(0.0, 60.0));
h.run_steps(2);
h.drop_at(beside + egui::vec2(0.0, 60.0));
h.run_steps(4);
assert_eq!(square(&h), Some(before), "the comment stays where it was");
}
/// Only the primary button draws a signature rectangle: a right-drag opens no signing dialog.
#[test]
fn a_right_drag_draws_no_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));
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE };
h.hover_at(a);
h.run_steps(1);
h.event(button(a, true));
h.run_steps(1);
h.hover_at(b);
h.run_steps(2);
h.event(button(b, false));
h.run_steps(4);
let s = h.state();
assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "the tool stays: no rectangle was drawn");
assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog);
}
/// Escape with the button down drops the press: its release places no stamp, and the next click
/// places one.
#[test]
fn escape_mid_press_keeps_its_release_from_placing_a_stamp() {
let mut h = open(link_pdf(), "150", false);
stamp_tool(&mut h);
let p = at(&h, 150.0, 150.0);
h.hover_at(p);
h.run_steps(1);
h.drag_at(p);
h.run_steps(1);
h.key_press(egui::Key::Escape);
h.run_steps(1);
h.drop_at(p);
h.run_steps(4);
assert_eq!(stamps(&h), 0, "the cancelled press places nothing on release");
mouse_click(&mut h, p);
assert_eq!(stamps(&h), 1, "the next click places a stamp");
}
/// Escape mid-drag abandons a crop rectangle: the page keeps its boxes.
#[test]
fn escape_cancels_a_crop_rectangle() {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Crop;
h.run_steps(4);
let (a, b) = (at(&h, 40.0, 360.0), at(&h, 260.0, 60.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);
h.key_press(egui::Key::Escape);
h.run_steps(1);
h.drop_at(b);
h.run_steps(4);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), None, "no page box was set");
}