Files
pdfcraft/crates/ui-egui/tests/it/pointer.rs
T
alehelandClaude Opus 5.5 e69d1221ff fix(ui): a right-click between the pages deselects the comment; Paste keeps to the picture's page
Two of CodeRabbit's findings on #5803. A right-click off the pages
cleared only the menu's anchor and kept the selected comment, so the
menu that opened was that comment's, not the current page's. A
right-click on a page's empty part deselects it, so one that no page
takes (`PageFrame.right_clicked`) now does the same. A selected Prepare
field keeps its menu off the pages, as it does on a page's empty part;
the comment there says so.

Where pages touch, the first page takes a right-click on the row they
share and anchors the menu there, but a picture on the second page under
it still opens its menu. Paste from that menu centred the image on page
1's point, which on page 2 is at its foot. The image menu now uses the
anchor only on the picture's own page, and otherwise pastes as Ctrl+V
does.

Tests, each seen failing with its fix reverted:
`a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments`,
`paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:41:30 +01:00

1821 lines
82 KiB
Rust
Raw 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::{Dialog, 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");
}
// ---------------------------------------------------------------------------------------------
// One link test: what the Select tool follows, the Link tool edits and a right-click opens is
// the same link.
/// A 300×400 page with two web links overlapping at x 150..200, y 300..350: `first` first in
/// `/Annots`, then `second` drawn over it.
fn stacked_links_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 200 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first) >> >>",
"<< /Type /Annot /Subtype /Link /Rect [150 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/second) >> >>",
])
}
/// A 300×400 page with a link that does nothing (no action) at x 10..40, y 300..350, then a web
/// link at x 50..250 beside it: second in `/Annots`, and the first the renderer can follow.
fn dead_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [10 300 40 350] /Border [0 0 0] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
])
}
/// [`link_pdf`], with a rectangle comment drawn over the middle of its link.
fn note_over_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
"<< /Type /Annot /Subtype /Square /Rect [100 310 200 340] /C [1 0 0] /F 4 /T (Ana) /Contents (A note) >>",
])
}
fn link_tool(h: &mut Harness<'_, PdfCraftApp>) {
h.state_mut().quick_tool = QuickTool::Link;
h.run_steps(4);
}
/// A right-click at `p`: press and release in frames of their own.
fn right_click(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(3);
}
fn pending_url(h: &Harness<'_, PdfCraftApp>) -> Option<String> {
h.state().pending_link.as_ref().map(|l| l.url.clone())
}
/// Of two stacked links the Link tool selects the one drawn on top, the one the Select tool
/// follows. It used to select the one underneath.
#[test]
fn the_link_tool_selects_the_topmost_of_stacked_links() {
let mut h = open(stacked_links_pdf(), "150", false);
let both = at(&h, 175.0, 325.0);
mouse_click(&mut h, both);
assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/second"), "the Select tool follows the link on top");
h.state_mut().pending_link = None;
link_tool(&mut h);
mouse_click(&mut h, both);
assert_eq!(h.state().views[0].links.selected, Some((0, 1)), "the Link tool selects it too");
}
/// The Link tool edits every link, one that does nothing included. The Select tool, which
/// follows links, shows no hand over it, and follows the link beside it by its own `/Annots`
/// place, not by its place among the links the renderer can follow.
#[test]
fn the_link_tool_selects_a_link_with_no_action() {
let mut h = open(dead_link_pdf(), "150", false);
let dead = at(&h, 25.0, 325.0);
assert_ne!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand, "the Select tool has nothing to follow there");
let web = at(&h, 150.0, 325.0);
mouse_click(&mut h, web);
assert_eq!(pending_url(&h).as_deref(), Some("https://example.org/"), "the web link is followed");
h.state_mut().pending_link = None;
link_tool(&mut h);
assert_eq!(cursor_at(&mut h, dead), egui::CursorIcon::PointingHand);
mouse_click(&mut h, dead);
assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the Link tool selects the link that does nothing");
}
/// With the Link tool a drag that starts on a link draws a new link: the press is the link's, its
/// drag is not.
#[test]
fn a_drag_with_the_link_tool_from_a_link_draws_a_new_link() {
let mut h = open(link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 150.0, 325.0);
mouse_drag(&mut h, on_link, egui::vec2(80.0, 120.0));
let s = h.state();
assert_eq!(s.dialog, Some(Dialog::LinkProps));
assert_eq!(s.link_draft.as_ref().map(|d| d.index), Some(None), "Link Properties opens for a new link");
}
/// With the Select tool a right-click finds a link only where a click follows it: just beside the
/// link it opens the page's menu. The Link tool's margin around links used to reach there.
#[test]
fn a_right_click_beside_a_link_with_the_select_tool_opens_no_link_menu() {
let mut h = open(link_pdf(), "150", false);
// The link ends at x 250.
let beside = at(&h, 250.0, 325.0) + egui::vec2(1.5, 0.0);
right_click(&mut h, beside);
assert_eq!(h.state().views[0].links.context, None, "no link under the right-click");
assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items");
h.key_press(egui::Key::Escape);
h.run_steps(3);
let on_link = at(&h, 245.0, 325.0);
right_click(&mut h, on_link);
assert_eq!(h.state().views[0].links.context, Some((0, 0)), "on the link, its menu");
}
/// A right-click goes to what a press there would go to: on a note over a link, the note. It used
/// to open the link's menu.
#[test]
fn a_right_click_on_a_note_over_a_link_opens_the_notes_menu() {
let mut h = open(note_over_link_pdf(), "150", false);
let on_note = at(&h, 150.0, 325.0);
right_click(&mut h, on_note);
let v = &h.state().views[0];
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the note is selected, not the link");
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the note's");
}
/// [`link_pdf`], with a highlight drawn over the middle of its link.
fn highlight_over_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
"<< /Type /Annot /Subtype /Highlight /Rect [100 310 200 340] /QuadPoints [100 340 200 340 100 310 200 310] /C [1 1 0] /F 4 /T (Ana) >>",
])
}
/// A click on a highlight over a link follows the link, but a right-click there is the
/// highlight's: it is selected and its menu opens. The link's menu used to open, with the
/// highlight selected underneath.
#[test]
fn a_right_click_on_a_highlight_over_a_link_opens_the_highlights_menu() {
let mut h = open(highlight_over_link_pdf(), "150", false);
let on_both = at(&h, 150.0, 325.0);
right_click(&mut h, on_both);
let v = &h.state().views[0];
assert_eq!((v.links.context, v.comments.selected), (None, Some((0, 1))), "the highlight is selected, not the link");
assert!(h.query_by_label("Remove all links").is_none(), "the menu is the highlight's");
h.key_press(egui::Key::Escape);
h.run_steps(3);
mouse_click(&mut h, on_both);
assert!(h.state().pending_link.is_some(), "a click there still follows the link");
}
/// A comment's menu opens on a right-click whatever the tool, though a press there is the tool's:
/// with the Rectangle tool, a right-click on a comment selects it.
#[test]
fn a_right_click_on_a_comment_with_a_drawing_tool_opens_its_menu() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
h.run_steps(4);
let on_comment = at(&h, 100.0, 305.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
assert!(h.query_all_by_label("Reply").next().is_some(), "and its menu is open");
}
/// The check box `agree` of [`checkbox_pdf`], with a rectangle comment at x 150..250, y 100..150.
fn comment_and_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 /Square /Rect [150 100 250 150] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// A click on anything but the selected comment leaves it unselected, a form field included, as
/// a click on a link or on the page does. A field's click used to keep it selected.
#[test]
fn a_click_on_a_form_field_deselects_the_selected_comment() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
let comment = at(&h, 200.0, 125.0);
mouse_click(&mut h, comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the comment is selected");
let check_box = at(&h, 110.0, 210.0);
mouse_click(&mut h, check_box);
assert_eq!(h.state().views[0].comments.selected, None, "a click on the check box leaves it unselected");
assert_eq!(agree(&h).len(), 1, "and ticks the box");
}
/// The camera tool's quadrilateral, corner by corner, in page view points.
fn camera_corners(h: &Harness<'_, PdfCraftApp>) -> Option<[[f32; 2]; 4]> {
h.state().views[0].camera.map(|q| q.corners)
}
/// A corner of the camera tool's quadrilateral is a press of its own: Escape in the middle of its
/// drag cancels it, and once the button is up no corner follows the pointer. Today Escape also
/// leaves the camera tool before the pages see it; this holds whatever the order of the keys.
#[test]
fn escape_mid_corner_drag_leaves_no_corner_following_the_pointer() {
let mut h = open(text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
// The left top corner starts 5% in from the page's edges: view (15, 20) on the 300×400 page.
let corner = at(&h, 15.0, 380.0);
let start = camera_corners(&h);
h.hover_at(corner);
h.run_steps(1);
h.drag_at(corner);
h.run_steps(1);
h.hover_at(corner + egui::vec2(30.0, 30.0));
h.run_steps(2);
assert_ne!(camera_corners(&h), start, "the corner follows its drag");
h.key_press(egui::Key::Escape);
h.run_steps(1);
let end = corner + egui::vec2(60.0, 60.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(2);
let after = camera_corners(&h);
h.hover_at(corner + egui::vec2(120.0, 90.0));
h.run_steps(3);
assert_eq!(camera_corners(&h), after, "no corner follows the pointer once the press is cancelled");
assert!(h.state().views[0].camera.is_none_or(|q| q.grab.is_none()), "and none is held");
}
/// A 300×400 page with a text field `name` (tooltip "Your name") at x 50..250, y 200..220, and a
/// rectangle comment drawn over its right end, x 150..260, y 190..230.
fn commented_text_field_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 5 0 R] >>",
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /TU (Your name) /Rect [50 200 250 220] /P 3 0 R /F 4 /DA (/Helv 12 Tf 0 g) >>",
"<< /Type /Annot /Subtype /Square /Rect [150 190 260 230] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// Rests the pointer at `p` long enough for a tooltip.
fn rest_at(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2) {
h.hover_at(p);
for _ in 0..40 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
}
}
/// A field's tooltip (`/TU`) shows only where a press would take the field: not under a comment
/// drawn over it. It used to show over the whole field.
#[test]
fn a_fields_tooltip_shows_only_where_the_field_takes_the_press() {
let mut h = open(commented_text_field_pdf(), "150", false);
let under_comment = at(&h, 200.0, 210.0);
rest_at(&mut h, under_comment);
assert!(h.query_by_label("Your name").is_none(), "no field tooltip where the comment takes the press");
let on_field = at(&h, 100.0, 210.0);
rest_at(&mut h, on_field);
assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip where it takes the press");
}
/// With the Link tool a right-click on a link is the link's, as its press there is, though a note
/// lies over it: the Link tool edits links and reaches no comment.
#[test]
fn a_right_click_with_the_link_tool_on_a_note_over_a_link_selects_the_link() {
let mut h = open(note_over_link_pdf(), "150", false);
link_tool(&mut h);
let on_both = at(&h, 150.0, 325.0);
right_click(&mut h, on_both);
let v = &h.state().views[0];
assert_eq!((v.links.selected, v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 0)), None), "the link, not the note");
}
/// With Add text a right-click leaves comments alone, as before: Add text takes the page's
/// right-clicks.
#[test]
fn a_right_click_with_add_text_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::AddText;
h.run_steps(4);
// Above the line of text the comment covers, where no paragraph is.
let on_comment = at(&h, 100.0, 317.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, None, "the comment under the pointer is not selected");
}
/// With the Select tool a right-click finds a link only where a click follows it: a link that
/// does nothing has no menu. The Link tool's own test used to give it one.
#[test]
fn a_right_click_with_the_select_tool_on_a_link_with_no_action_opens_no_link_menu() {
let mut h = open(dead_link_pdf(), "150", false);
let dead = at(&h, 25.0, 325.0);
right_click(&mut h, dead);
assert_eq!(h.state().views[0].links.context, None);
assert!(h.query_by_label("Remove all links").is_none(), "the page's menu has no link items");
}
/// [`link_pdf`], with a check box over the middle of its link.
fn field_over_link_pdf() -> Vec<u8> {
pdf(&[
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [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 /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [140 315 160 335] /P 3 0 R /F 4 /MK << /CA (4) >> /DA (/ZaDb 11 Tf 0 g) >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>",
])
}
/// A right-click on a form field over a link is the field's, as a press there is: no link menu.
#[test]
fn a_right_click_on_a_field_over_a_link_opens_no_link_menu() {
let mut h = open(field_over_link_pdf(), "150", false);
let on_field = at(&h, 150.0, 325.0);
right_click(&mut h, on_field);
assert_eq!(h.state().views[0].links.context, None, "the field is on top");
assert!(h.query_by_label("Remove all links").is_none());
}
/// While the palette is open, a field beside it still shows its tooltip, as before: the palette
/// keeps the page's own tips away, not egui's tooltip of a field.
#[test]
fn a_fields_tooltip_shows_beside_the_open_palette() {
let mut h = open(commented_text_field_pdf(), "150", 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 on_field = at(&h, 60.0, 210.0);
assert!(!palette.expand(20.0).contains(on_field), "the field is beside the palette {palette:?}");
rest_at(&mut h, on_field);
assert!(h.query_all_by_label("Your name").next().is_some(), "the field's tooltip shows");
}
/// Two blank 300×400 pages.
fn two_pages_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Page /Parent 2 0 R >>",
])
}
/// Sets a tool up (`tool`), presses above page 1's bottom edge and drags onto page 2 in one frame,
/// so egui sees the drag only once the pointer is on page 2, and returns the app after the release.
fn drag_seen_only_past_page_ones_edge(tool: impl FnOnce(&mut PdfCraftApp)) -> Harness<'static, PdfCraftApp> {
let mut h = open(two_pages_pdf(), "100", false);
tool(h.state_mut());
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let press = egui::pos2(one.min.x + 50.0, one.max.y - 30.0);
let release = egui::pos2(one.min.x + 150.0, two.min.y + 20.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.hover_at(release);
h.run_steps(1);
h.drop_at(release);
h.run_steps(4);
h
}
// A drag seen only past its page's edge still draws on the page its press began on, as zoom, crop
// and Sign area do. With these three it used to draw nothing anywhere.
#[test]
fn a_redaction_box_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Redact);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.info.annotations.iter().any(|a| a.subtype == "Redact" && a.page == 0), "a redaction mark on page 1");
}
#[test]
fn a_link_rectangle_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| app.quick_tool = QuickTool::Link);
assert_eq!(h.state().link_draft.as_ref().map(|d| (d.page, d.index)), Some((0, None)), "a new link on page 1");
}
#[test]
fn a_field_dragged_off_its_page_is_drawn_on_it() {
let h = drag_seen_only_past_page_ones_edge(|app| assert!(app.execute("form.add.text"), "Prepare a form, with the text field tool"));
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert!(doc.form.iter().any(|f| f.widgets.iter().any(|w| w.page == Some(0))), "a field on page 1");
}
/// With the Link tool a right-click on a link is the link's and leaves comments as they are, as
/// before: the selected comment stays selected.
#[test]
fn a_right_click_with_the_link_tool_on_a_link_keeps_the_selected_comment() {
let mut h = open(note_over_link_pdf(), "150", false);
let on_note = at(&h, 150.0, 325.0);
mouse_click(&mut h, on_note);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note is selected");
link_tool(&mut h);
// On the link, away from the note.
let on_link = at(&h, 70.0, 325.0);
right_click(&mut h, on_link);
let v = &h.state().views[0];
assert_eq!((v.links.context, v.comments.selected), (Some((0, 0)), Some((0, 1))), "the link's menu; the note stays selected");
}
/// A 300×400 page with a rectangle comment round the camera tool's first corner (view 15, 20).
fn comment_at_camera_corner_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Square /Rect [5 365 45 395] /C [1 0 0] /F 4 /T (Ana) >>",
])
}
/// A camera corner has no menu: a right-click on a comment under it opens the comment's, as
/// before.
#[test]
fn a_right_click_on_a_comment_under_a_camera_corner_opens_the_comments_menu() {
let mut h = open(comment_at_camera_corner_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
let corner = at(&h, 15.0, 380.0);
right_click(&mut h, corner);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected for its menu");
}
/// A 300×400 page with a web link along its left edge, x 0..100, y 300..350.
fn edge_link_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
PAGES,
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
"<< /Type /Annot /Subtype /Link /Rect [0 300 100 350] /Border [0 0 0] /A << /S /URI /URI (https://example.org/) >> >>",
])
}
/// The Link tool's margin round a link stops at the page's edge: beside the page, where a press
/// does nothing, no hand promises one (as before the margin moved into the one link test).
#[test]
fn the_link_tools_hand_stops_at_the_pages_edge() {
let mut h = open(edge_link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 10.0, 325.0);
assert_eq!(cursor_at(&mut h, on_link), egui::CursorIcon::PointingHand, "on the link");
let beside = at(&h, 0.0, 325.0) - egui::vec2(1.0, 0.0);
assert_ne!(cursor_at(&mut h, beside), egui::CursorIcon::PointingHand, "just off the page");
}
/// With Edit text a right-click on a paragraph is Edit text's, as before, though a comment lies
/// over it: the comment is not selected. The page's menu is anchored there all the same (Paste on
/// the picture menu, Delete page); it used to stay where the last right-click comments saw was.
#[test]
fn a_right_click_with_edit_text_on_a_paragraph_under_a_comment_selects_no_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
let on_text = at(&h, 100.0, 303.0);
wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
right_click(&mut h, on_text);
assert_eq!(h.state().views[0].comments.selected, None);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "the menu is anchored on the page");
}
/// With a Prepare field tool a right-click is the tool's, as before: a comment under it is not
/// selected.
#[test]
fn a_right_click_with_a_field_tool_selects_no_comment() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.add.text"));
h.run_steps(4);
let on_comment = at(&h, 200.0, 125.0);
right_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, None);
}
/// With the Link tool a click that began off the page (in the gutter beside it) is no page's: the
/// selected link stays selected. It used to unselect it.
#[test]
fn a_click_that_began_off_the_page_keeps_the_selected_link() {
let mut h = open(link_pdf(), "150", false);
link_tool(&mut h);
let on_link = at(&h, 150.0, 325.0);
mouse_click(&mut h, on_link);
assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "the link is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, on_link.y), egui::pos2(left + 3.0, on_link.y));
assert_eq!(h.state().views[0].links.selected, Some((0, 0)), "a click that began off the page leaves it selected");
}
/// A right-click while a comment is moved leaves the moved comment selected: the move ends on
/// it. The right-click used to select the comment under it mid-drag.
#[test]
fn a_right_click_during_a_comment_move_keeps_the_moved_comment_selected() {
let mut h = open(stacked_comments_pdf(), "150", false);
// "Bottom note" alone, then "Top note" alone.
let (bottom, top) = (at(&h, 60.0, 160.0), at(&h, 190.0, 290.0));
h.hover_at(bottom);
h.run_steps(1);
h.drag_at(bottom);
h.run_steps(1);
h.hover_at(bottom + egui::vec2(0.0, 20.0));
h.run_steps(1);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: top, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
let end = bottom + egui::vec2(0.0, 40.0);
h.hover_at(end);
h.run_steps(1);
h.drop_at(end);
h.run_steps(3);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the moved comment stays selected");
}
/// A click on added text leaves the selected comment unselected, as a click on a form field does
/// (ruling 1). It used to keep it selected.
#[test]
fn a_click_on_added_text_deselects_the_selected_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
// The Edit panel open, with the Select tool: added text is picked up.
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = QuickTool::Select;
h.run_steps(3);
let on_comment = at(&h, 150.0, 315.0);
mouse_click(&mut h, on_comment);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
let on_added = at(&h, 90.0, 202.0);
mouse_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None, "a click on the added text leaves it unselected");
}
/// A comment gesture left over with no button down (a release the pages never saw) does not keep
/// a right-click from selecting the comment under it.
#[test]
fn a_right_click_selects_its_comment_though_a_gesture_was_left_over() {
let mut h = open(stacked_comments_pdf(), "150", false);
let bottom = at(&h, 60.0, 160.0);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: bottom });
let top = at(&h, 190.0, 290.0);
right_click(&mut h, top);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the right-click is selected");
}
/// A click that began off the page (in the gutter beside it) is no page's: the selected comment
/// stays selected, as the Link tool's and Prepare's selections do. It used to unselect it.
#[test]
fn a_click_that_began_off_the_page_keeps_the_selected_comment() {
let mut h = open(stacked_comments_pdf(), "150", false);
let bottom = at(&h, 60.0, 160.0);
mouse_click(&mut h, bottom);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the note is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, bottom.y), egui::pos2(left + 3.0, bottom.y));
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "a click that began off the page leaves it selected");
}
/// A touch's long press on a comment selects it for its menu, as a right-click does: egui reports
/// it as a right-click with the finger still down, which holds no drag.
#[test]
fn a_long_press_on_a_comment_selects_it_for_its_menu() {
let mut h = open(stacked_comments_pdf(), "150", false);
let top = at(&h, 190.0, 290.0);
let touch = |phase, pos| egui::Event::Touch { device_id: egui::TouchDeviceId(1), id: egui::TouchId(1), phase, pos, force: None };
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [touch(egui::TouchPhase::Start, top), egui::Event::PointerMoved(top), button(top, true)]);
// Held still, past egui's long-press time.
h.run_steps(6);
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "the note under the finger is selected");
one_frame(&mut h, [touch(egui::TouchPhase::End, top), button(top, false), egui::Event::PointerGone]);
h.run_steps(2);
}
/// With the page menu anchored on page 1, presses on page 2 and holds the button, presses Escape
/// first with `escape`, right-clicks on page 2, and gives the page the menu is anchored on.
fn menu_page_after_a_right_click_with_the_button_held(escape: bool) -> Option<usize> {
let mut h = open(two_pages_pdf(), "100", false);
h.run_steps(2);
h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0]));
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
let press = two.min + egui::vec2(50.0, 30.0);
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
h.hover_at(press + egui::vec2(40.0, 0.0));
h.run_steps(1);
if escape {
h.key_press(egui::Key::Escape);
h.run_steps(1);
}
let menu = press + egui::vec2(100.0, 60.0);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: menu, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
}
h.run_steps(1);
let page = h.state().views[0].comments.context_at.map(|(page, _)| page);
h.drop_at(press + egui::vec2(40.0, 0.0));
h.run_steps(2);
page
}
/// A right-click while the primary button is held anchors the page's menu where it is (Delete page,
/// Paste): the held press leaves only the selections alone. It used to keep the menu on the page
/// right-clicked before.
#[test]
fn a_right_click_with_the_button_held_anchors_the_menu_on_its_page() {
assert_eq!(menu_page_after_a_right_click_with_the_button_held(false), Some(1), "the menu is page 2's");
}
/// So does a right-click while a press Escape cancelled is held, though the cancelled press takes
/// nothing else.
#[test]
fn a_right_click_after_escape_cancelled_the_press_anchors_the_menu_on_its_page() {
assert_eq!(menu_page_after_a_right_click_with_the_button_held(true), Some(1), "the menu is page 2's");
}
/// With Edit text a right-click on added text reaches comments, as before (Edit text keeps those on
/// its own paragraphs and pictures): the selected comment is unselected.
#[test]
fn a_right_click_with_edit_text_on_added_text_unselects_the_comment() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
h.state_mut().quick_tool = QuickTool::EditText;
h.run_steps(4);
h.state_mut().views[0].comments.selected = Some((0, 0));
let on_added = at(&h, 90.0, 202.0);
right_click(&mut h, on_added);
assert_eq!(h.state().views[0].comments.selected, None);
}
/// Two pages with their gaps hidden, after `tool` set the tool up, and a point on the row of pixels
/// where they touch, which lies on both.
fn touching_pages(tool: impl FnOnce(&mut PdfCraftApp)) -> (Harness<'static, PdfCraftApp>, egui::Pos2) {
let mut h = open(two_pages_pdf(), "100", false);
h.state_mut().views[0].show_page_gaps = false;
tool(h.state_mut());
h.run_steps(4);
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let seam = egui::pos2(one.min.x + 100.0, one.max.y);
assert!(one.contains(seam) && two.contains(seam), "the pages touch: {one:?} {two:?}");
(h, seam)
}
/// The pages the document's stamps are on.
fn stamp_pages(h: &Harness<'_, PdfCraftApp>) -> Vec<usize> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.iter().filter(|a| a.subtype == "Stamp").map(|a| a.page).collect()
}
/// Where pages touch (the gaps hidden), a press on the row they share is the first page's: a stamp
/// clicked there lands on page 1. The second page used to take the press over.
#[test]
fn a_stamp_clicked_where_two_pages_touch_lands_on_the_first() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved));
mouse_click(&mut h, seam);
assert_eq!(stamp_pages(&h), vec![0]);
}
/// A click pressed on one page and released on the next (the gaps hidden, within egui's click
/// distance) places nothing: the press is the first page's, and the pointer is off it. `main` placed
/// the stamp on the second page.
#[test]
fn a_stamp_click_pressed_on_one_page_and_released_on_the_next_places_nothing() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::Stamp(StampKind::DynApproved));
slipping_click(&mut h, seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 2.0));
assert_eq!(stamp_pages(&h), Vec::<usize>::new());
}
/// Where pages touch (the gaps hidden), a right-click on the row they share is the first page's:
/// the menu is anchored there. Both pages used to take it, and the second's anchor stood.
#[test]
fn a_right_click_where_two_pages_touch_is_the_first_pages() {
let (mut h, seam) = touching_pages(|_| {});
right_click(&mut h, seam);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0));
}
/// With Prepare open, a click that began off the page (in the gutter beside it) is no page's: the
/// selected field stays selected, as the Link tool's and comments' selections do.
#[test]
fn with_prepare_a_click_that_began_off_the_page_keeps_the_selected_field() {
let mut h = open(comment_and_checkbox_pdf(), "150", false);
assert!(h.state_mut().execute("form.prepare"));
h.run_steps(4);
let on_box = at(&h, 110.0, 210.0);
mouse_click(&mut h, on_box);
assert!(h.state().views[0].prepare.selected.is_some(), "the field is selected");
let left = page(&h).min.x;
slipping_click(&mut h, egui::pos2(left - 2.0, on_box.y), egui::pos2(left + 3.0, on_box.y));
assert!(h.state().views[0].prepare.selected.is_some(), "a click that began off the page leaves it selected");
}
/// With the Edit panel open and the Note tool, a click on added text is the added text's: no note is
/// started under it, as before.
#[test]
fn a_note_tool_click_on_added_text_starts_no_note() {
let mut h = open(comment_on_text_pdf(), "150", false);
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddText {
page: 0,
text: pdfcraft_engine::AddedText { rect: [40.0, 195.0, 140.0, 210.0], text: "Added".into(), ..Default::default() },
}));
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Note);
h.run_steps(3);
let on_added = at(&h, 90.0, 202.0);
mouse_click(&mut h, on_added);
assert!(h.state().views[0].comments.composer.is_none(), "no note is started");
}
/// With Fill & Sign, a mark being moved on the page keeps the page's presses, as before: a click while
/// its move is still live (its release never seen) places no new mark.
#[test]
fn a_fill_and_sign_click_places_nothing_while_a_mark_is_moved() {
let mut h = open(comment_on_text_pdf(), "150", false);
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Check);
h.run_steps(4);
let marks = |h: &Harness<'_, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().info.annotations.len()
};
let before = marks(&h);
let blank = at(&h, 150.0, 100.0);
h.state_mut().views[0].comments.gesture = Some(pdfcraft_ui_egui::comments::Gesture::Move { page: 0, index: 0, from: blank });
mouse_click(&mut h, blank);
assert_eq!(marks(&h), before, "no mark is placed");
}
/// A right-click between the pages anchors no page's menu, so Delete page and Paste act on the
/// current page. The menu used to keep the anchor of the last right-click on a page.
#[test]
fn a_right_click_between_the_pages_anchors_no_pages_menu() {
let mut h = open(two_pages_pdf(), "100", false);
h.run_steps(2);
h.state_mut().views[0].comments.context_at = Some((0, [10.0, 10.0]));
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0);
assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}");
right_click(&mut h, gap);
assert_eq!(h.state().views[0].comments.context_at, None);
}
/// Two 300×400 pages, a rectangle comment on the first.
fn two_pages_and_a_comment_pdf() -> Vec<u8> {
pdf(&[
CATALOG,
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
"<< /Type /Page /Parent 2 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [30 290 200 320] /C [1 0 0] /F 4 /T (Reviewer) >>",
])
}
/// A right-click between the pages selects no comment, as one on a page's empty part does not, so
/// the menu that opens is the current page's. The selected comment's menu used to open there.
#[test]
fn a_right_click_between_the_pages_opens_the_page_menu_not_the_selected_comments() {
let mut h = open(two_pages_and_a_comment_pdf(), "100", false);
h.state_mut().views[0].comments.selected = Some((0, 0));
h.run_steps(2);
assert_eq!(h.state().views[0].comments.selected, Some((0, 0)), "the comment is selected");
let (one, two) = {
let v = &h.state().views[0];
(v.page_screen_rect(0).expect("page 1 on screen"), v.page_screen_rect(1).expect("page 2 on screen"))
};
let gap = egui::pos2(one.center().x, (one.max.y + two.min.y) / 2.0);
assert!(!one.contains(gap) && !two.contains(gap), "between the pages: {one:?} {two:?}");
right_click(&mut h, gap);
assert_eq!(h.state().views[0].comments.selected, None);
assert!(h.query_by_label("Rotate view counterclockwise").is_some(), "the page menu opens");
assert!(h.query_by_label("Reply").is_none(), "not the comment's");
}
/// Where pages touch, a right-click on the row they share is page 1's, and a picture on page 2 there
/// opens its menu, whose Paste then places the image as Ctrl+V does. It used to centre the image on
/// page 1's point, which on page 2 lies at its foot.
#[test]
fn paste_from_a_picture_menu_where_two_pages_touch_keeps_off_the_first_pages_point() {
let (mut h, seam) = touching_pages(|app| {
app.system_clipboard = false;
app.quick_tool = QuickTool::EditText;
});
let clip = || pdfcraft_ui_egui::Clip::Image { width: 60, height: 40, rgba: [50u8, 150, 250, 255].repeat(60 * 40) };
h.state_mut().clipboard_override = Some(clip());
assert!(h.state_mut().paste_image_at(1, [100.0, 400.0]), "a picture across page 2's top edge");
h.state_mut().clipboard_override = Some(clip());
h.state_mut().views[0].image_selection = None;
h.run_steps(4);
right_click(&mut h, seam);
assert_eq!(h.state().views[0].comments.context_at.map(|(page, _)| page), Some(0), "page 1 takes the right-click");
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| s.page), Some(1), "page 2's picture opens its menu");
h.get_by_label("Paste").click();
h.run_steps(3);
let pasted = {
let s = h.state();
s.session.get(s.views[0].id).unwrap().page_images(1)
};
assert_eq!(pasted.len(), 2, "the image is pasted on page 2");
let r = pasted[1].rect;
assert!((r[1] + r[3]) / 2.0 > 100.0, "not centred at page 2's foot: {r:?}");
}
/// With the camera tool, a click on another page moves the quadrilateral there, as a drag from it
/// does. The click was located by its press's origin, which egui has cleared by the release frame,
/// so it did nothing (on `main` too).
#[test]
fn a_camera_click_on_another_page_moves_the_quadrilateral_there() {
let mut h = open(two_pages_pdf(), "100", false);
h.state_mut().quick_tool = QuickTool::EnhanceCamera;
h.run_steps(4);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page");
let two = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
mouse_click(&mut h, two.min + egui::vec2(100.0, 60.0));
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(1));
}
/// A camera click is located where it came up, not where the pointer went after it in the same
/// frame: pressed on page 2 and released on page 1 (the gaps hidden), it moves nothing, though the
/// pointer is back on page 2 by the frame's end. The pointer's last position used to move the
/// quadrilateral to page 2.
#[test]
fn a_camera_click_released_off_its_page_moves_nothing() {
let (mut h, seam) = touching_pages(|app| app.quick_tool = QuickTool::EnhanceCamera);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0), "it starts on the current page");
let (press, release, back) = (seam + egui::vec2(0.0, 2.0), seam - egui::vec2(0.0, 2.0), seam + egui::vec2(0.0, 40.0));
h.hover_at(press);
h.run_steps(1);
h.drag_at(press);
h.run_steps(1);
let up = egui::Event::PointerButton { pos: release, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE };
one_frame(&mut h, [up, egui::Event::PointerMoved(back)]);
h.run_steps(2);
assert_eq!(h.state().views[0].camera.map(|q| q.page), Some(0));
}