mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 04:28:20 -06:00
634 lines
26 KiB
Rust
634 lines
26 KiB
Rust
//! What the page's tools see of the pointer. A finger tap, a touch drag and a click whose press
|
||
//! and release share a frame act as a mouse click and drag do, and nothing drawn over the page (a
|
||
//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's
|
||
//! cursor, tips or presses through.
|
||
//!
|
||
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor
|
||
//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4).
|
||
|
||
use egui_kittest::Harness;
|
||
use pdfcraft_engine::StampKind;
|
||
use pdfcraft_ui_egui::comments::CommentTool;
|
||
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
|
||
|
||
use crate::support::input::{coalesced_click, double_click, 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 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 >>",
|
||
])
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
|
||
/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from
|
||
/// worker threads), and returns the last cursor seen.
|
||
fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
|
||
let mut cursor = egui::CursorIcon::Default;
|
||
for _ in 0..50 {
|
||
cursor = cursor_at(h, p);
|
||
if want(cursor) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||
}
|
||
cursor
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// The cursor promises one thing and a press does another.
|
||
|
||
#[test]
|
||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||
fn stamp_tool_over_a_link_shows_its_crosshair() {
|
||
let mut h = open(link_pdf(), "150", false);
|
||
stamp_tool(&mut h);
|
||
let on_link = at(&h, 150.0, 325.0);
|
||
let off_link = at(&h, 150.0, 150.0);
|
||
assert_eq!(cursor_at(&mut h, off_link), egui::CursorIcon::Crosshair, "the tool's cursor off the link");
|
||
let cursor = cursor_at(&mut h, on_link);
|
||
mouse_click(&mut h, on_link);
|
||
assert_eq!((stamps(&h), h.state().pending_link.is_some()), (1, false), "a click places a stamp and does not follow the link");
|
||
assert_eq!(cursor, egui::CursorIcon::Crosshair, "so the cursor is the stamp tool's, not the link hand");
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||
fn rectangle_tool_over_a_link_shows_its_crosshair() {
|
||
let mut h = open(link_pdf(), "150", false);
|
||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||
h.run_steps(4);
|
||
let off_link = at(&h, 150.0, 150.0);
|
||
let off = cursor_at(&mut h, off_link);
|
||
let on_link = at(&h, 150.0, 325.0);
|
||
let on = cursor_at(&mut h, on_link);
|
||
assert_eq!(on, off, "a drag over the link draws a rectangle, as it does off the link");
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||
fn edit_text_over_a_linked_word_offers_the_move_arrow() {
|
||
let mut h = open(linked_text_pdf(), "150", false);
|
||
h.state_mut().quick_tool = QuickTool::EditText;
|
||
for _ in 0..30 {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
// One paragraph, one line: a word outside the link, then a word inside it.
|
||
let plain = at(&h, 230.0, 303.0);
|
||
let linked = at(&h, 60.0, 303.0);
|
||
let off = wait_for_cursor(&mut h, plain, |c| c == egui::CursorIcon::Move);
|
||
let on = cursor_at(&mut h, linked);
|
||
let id = h.state().views[0].id;
|
||
let before = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
|
||
let k = page(&h).width() / 300.0;
|
||
mouse_drag(&mut h, linked, egui::vec2(20.0 * k, 0.0));
|
||
let after = h.state().session.get(id).unwrap().text_blocks(0)[0].rect;
|
||
assert_eq!(off, egui::CursorIcon::Move, "a closed paragraph offers the four-way arrow (#1717)");
|
||
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the linked word moves the paragraph: {before:?} → {after:?}");
|
||
assert_eq!(on, off, "the linked words belong to the same paragraph, and a drag there moves it too");
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||
fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
|
||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||
// Inside the rectangle where there is no text, and inside it over a word.
|
||
let blank = at(&h, 120.0, 315.0);
|
||
let on_text = at(&h, 60.0, 303.0);
|
||
let over_blank = cursor_at(&mut h, blank);
|
||
let over_text = wait_for_cursor(&mut h, on_text, |c| c != egui::CursorIcon::Default);
|
||
let before = square(&h).expect("the rectangle");
|
||
let k = page(&h).width() / 300.0;
|
||
mouse_drag(&mut h, on_text, egui::vec2(20.0 * k, 0.0));
|
||
let after = square(&h).expect("the rectangle");
|
||
assert_eq!(over_blank, egui::CursorIcon::Move, "a movable comment offers the four-way arrow");
|
||
assert!((after[0] - before[0] - 20.0).abs() < 1.5, "a drag from the text moves the rectangle: {before:?} → {after:?}");
|
||
assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too");
|
||
}
|
||
|
||
/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points
|
||
/// left of the edge. Returns the cursor shown at the press point and the line count after.
|
||
fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) {
|
||
let mut h = open(text_pdf(), "150", false);
|
||
assert!(h.state_mut().execute("edit.edit_text"));
|
||
for _ in 0..20 {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let id = h.state().views[0].id;
|
||
let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone();
|
||
let k = page(&h).width() / 300.0;
|
||
let grip = at(&h, b.rect[0] as f32, (b.rect[1] + b.rect[3]) as f32 / 2.0) - egui::vec2(dx, 0.0);
|
||
let cursor = cursor_at(&mut h, grip);
|
||
h.drag_at(grip);
|
||
h.run_steps(1);
|
||
for i in 1..=4 {
|
||
h.hover_at(grip + egui::vec2(25.0 * k * i as f32, 0.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(grip + egui::vec2(100.0 * k, 0.0));
|
||
for _ in 0..10 {
|
||
h.run_steps(2);
|
||
}
|
||
(cursor, h.state().session.get(id).unwrap().text_lines(0).len())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "step 3 of the page pointer router: one hit test"]
|
||
fn a_paragraph_edge_resizes_wherever_it_shows_the_resize_cursor() {
|
||
let (inside_cursor, inside_lines) = narrow_from_left(1.0);
|
||
assert_eq!(inside_cursor, egui::CursorIcon::ResizeHorizontal);
|
||
assert!(inside_lines > 1, "control: a drag from the edge rewraps the paragraph");
|
||
let (outside_cursor, outside_lines) = narrow_from_left(5.0);
|
||
assert_eq!(outside_cursor, egui::CursorIcon::ResizeHorizontal, "the resize cursor shows 5 pt out");
|
||
assert!(outside_lines > 1, "so a drag from there rewraps it too");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------
|
||
// Things drawn over the page keep the page's cursor, tips and presses off.
|
||
|
||
#[test]
|
||
fn stamp_cursor_stays_off_the_command_palette() {
|
||
let mut h = open(link_pdf(), "300", false);
|
||
stamp_tool(&mut h);
|
||
h.state_mut().palette_open = true;
|
||
h.run_steps(4);
|
||
let palette = h.ctx.memory(|m| m.area_rect(egui::Id::new("palette"))).expect("palette shown");
|
||
// On the palette's frame margin, where none of its widgets sets a cursor, over the page.
|
||
let p = egui::pos2(palette.left() + 4.0, palette.bottom() - 4.0);
|
||
assert!(page(&h).contains(p), "the palette must lie over the page here");
|
||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
|
||
// The page beside the palette still shows the tool.
|
||
let beside = egui::pos2(palette.left() + 4.0, palette.bottom() + 40.0);
|
||
assert!(page(&h).contains(beside));
|
||
assert_eq!(cursor_at(&mut h, beside), egui::CursorIcon::Crosshair);
|
||
}
|
||
|
||
/// 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::new("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 tab carried over a link shows neither its tip nor its cursor, and the link answers
|
||
/// again once the tab is let go.
|
||
#[test]
|
||
fn a_dragged_panel_tab_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 Inspector's tab is picked up and carried over it.
|
||
h.hover_at(at(&h, 150.0, 100.0));
|
||
h.run_steps(4);
|
||
let tab = h.get_by(|n| n.role() == Role::Tab && n.label().as_deref() == Some("Inspector")).rect().center();
|
||
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 tab is still being dragged");
|
||
assert!(!tip_shown(&h), "no link tip under a dragged panel tab");
|
||
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
|
||
}
|
||
// Back to the tab and let go there, which leaves the layout 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");
|
||
}
|
||
|
||
fn wheel(h: &mut Harness<'_, PdfCraftApp>, delta: egui::Vec2) {
|
||
h.hover_at(egui::pos2(800.0, 500.0));
|
||
h.event(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::NONE, phase: egui::TouchPhase::Move });
|
||
for _ in 0..10 {
|
||
h.run_steps(2);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn stamp_cursor_stays_off_the_quick_bar() {
|
||
let mut h = open(link_pdf(), "400", true);
|
||
stamp_tool(&mut h);
|
||
// Bring the page under the quick bar's left end.
|
||
wheel(&mut h, egui::vec2(-200.0, 0.0));
|
||
let bar = h.ctx.memory(|m| m.area_rect(egui::Id::new("quick-bar"))).expect("quick bar shown");
|
||
// Inside the bar's frame margin: no button there.
|
||
let p = egui::pos2(bar.left() + 3.0, bar.bottom() - 3.0);
|
||
assert!(page(&h).contains(p), "the quick bar must lie over the page here");
|
||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::Default);
|
||
let below = egui::pos2(bar.left() + 3.0, bar.bottom() + 20.0);
|
||
assert!(page(&h).contains(below));
|
||
assert_eq!(cursor_at(&mut h, below), egui::CursorIcon::Crosshair);
|
||
}
|
||
|
||
#[test]
|
||
fn stamp_cursor_stays_off_the_chrome_above_a_page_scrolled_under_it() {
|
||
let mut h = open(link_pdf(), "400", true);
|
||
stamp_tool(&mut h);
|
||
let top = page(&h).top();
|
||
wheel(&mut h, egui::vec2(0.0, -300.0));
|
||
let page = page(&h);
|
||
// Above where the page started (the viewport's top edge, give or take its margin): the
|
||
// toolbar, with the page scrolled up under it. Left of the link, which shows the hand.
|
||
let x = page.left() + 20.0;
|
||
let p = egui::pos2(x, top - 45.0);
|
||
assert!(page.contains(p), "the page must extend under the chrome here");
|
||
// The toolbar keeps its own cursor (a button's hand here); the page's is gone.
|
||
assert_ne!(cursor_at(&mut h, p), egui::CursorIcon::Crosshair);
|
||
assert_eq!(cursor_at(&mut h, egui::pos2(x, top + 40.0)), egui::CursorIcon::Crosshair);
|
||
}
|
||
|
||
/// 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::new("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 });
|
||
}
|
||
|
||
#[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::new("unlock")));
|
||
assert!(card.is_none_or(|c| !c.contains(on_link)), "the point must be on the backdrop, not the card: {card:?}");
|
||
assert!(!tip_shown(&h), "the link's tip must not float over the Unlock prompt");
|
||
assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
|
||
}
|
||
|
||
#[test]
|
||
fn the_link_tip_lets_the_pointer_through_to_the_link() {
|
||
let mut h = open(link_pdf(), "150", false);
|
||
let p = at(&h, 100.0, 340.0);
|
||
assert_eq!(cursor_at(&mut h, p), egui::CursorIcon::PointingHand);
|
||
let tip = h.ctx.memory(|m| m.area_rect(egui::Id::new("canvas-hover"))).expect("the link's tip");
|
||
// A quick move lands on the tip, drawn beside where the pointer was, and still on the link.
|
||
let onto = tip.min + egui::vec2(6.0, 6.0);
|
||
assert!(egui::Rect::from_two_pos(at(&h, 50.0, 350.0), at(&h, 250.0, 300.0)).contains(onto), "the point must be on the link");
|
||
h.hover_at(onto);
|
||
h.run_steps(1);
|
||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the tip must not take the pointer from the link");
|
||
}
|
||
|
||
// 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");
|
||
}
|
||
|
||
#[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]
|
||
#[ignore = "step 4 of the page pointer router: press-time capture and cancel"]
|
||
fn escape_cancels_a_signature_rectangle() {
|
||
let mut h = open(link_pdf(), "150", false);
|
||
h.state_mut().quick_tool = QuickTool::SignArea { certify: false };
|
||
h.run_steps(4);
|
||
let (a, b) = (at(&h, 60.0, 200.0), at(&h, 200.0, 120.0));
|
||
h.hover_at(a);
|
||
h.run_steps(1);
|
||
h.drag_at(a);
|
||
h.run_steps(1);
|
||
h.hover_at(b);
|
||
h.run_steps(2);
|
||
// Escape while the button is still held.
|
||
h.key_press(egui::Key::Escape);
|
||
h.run_steps(4);
|
||
let s = h.state();
|
||
assert_eq!(s.quick_tool, QuickTool::SignArea { certify: false }, "Escape abandons the rectangle; the tool stays");
|
||
assert!(s.dialog.is_none(), "and no signing dialog opens: {:?}", s.dialog);
|
||
}
|