270 lines
12 KiB
Rust
270 lines
12 KiB
Rust
//! Issue #295: quick clicks on text widen the selection, as in Acrobat: two clicks select a
|
||
//! word, three the line, four all the text on the page.
|
||
|
||
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, Pos2, TouchPhase};
|
||
use egui_kittest::Harness;
|
||
use pdfcraft_ui_egui::PdfCraftApp;
|
||
|
||
/// One 300×200 page with two lines of Helvetica text.
|
||
fn two_lines() -> Vec<u8> {
|
||
let content = "BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET";
|
||
format!(
|
||
"%PDF-1.7
|
||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||
4 0 obj << /Length {} >> stream
|
||
{content}
|
||
endstream endobj
|
||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF",
|
||
content.len()
|
||
)
|
||
.into_bytes()
|
||
}
|
||
|
||
/// The page on screen and the pointer over the "u" of "quick". Frames are 1/60 s: a click takes
|
||
/// two frames (press, release), and egui counts a double-click only within 0.3 s.
|
||
fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) {
|
||
harness_with(two_lines(), setup)
|
||
}
|
||
|
||
/// `harness` with another document; the pointer ends over glyph 5 of its first page.
|
||
fn harness_with(pdf: Vec<u8>, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> (Harness<'static, PdfCraftApp>, Pos2) {
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("lines.pdf", None, pdf).expect("opens");
|
||
app.set_option("left", "closed").unwrap();
|
||
app.set_option("author", "Tester").unwrap();
|
||
setup(&mut app);
|
||
app
|
||
});
|
||
for _ in 0..200 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer loaded");
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
(h, at)
|
||
}
|
||
|
||
/// Press and release without moving or leaving the page.
|
||
fn click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) {
|
||
for pressed in [true, false] {
|
||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
|
||
}
|
||
h.run_steps(1);
|
||
}
|
||
|
||
#[test]
|
||
fn quick_clicks_select_a_word_then_the_line_then_the_page() {
|
||
let (mut h, at) = harness(|_| {});
|
||
let page = "The quick brown fox\njumps over the dog";
|
||
for (n, want) in [None, Some("quick"), Some("The quick brown fox"), Some(page), Some(page)].into_iter().enumerate() {
|
||
click(&mut h, at);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), want, "after {} clicks", n + 1);
|
||
}
|
||
// A pause ends the run: the next double-click selects a word again.
|
||
h.run_steps(60);
|
||
click(&mut h, at);
|
||
click(&mut h, at);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||
}
|
||
|
||
#[test]
|
||
fn quick_clicks_with_the_highlighter_mark_only_the_word() {
|
||
let (mut h, at) = harness(|app| assert!(app.execute("comment.highlight")));
|
||
for _ in 0..4 {
|
||
click(&mut h, at);
|
||
// The double-click's highlight re-renders the page; give its text layer up to 8 frames
|
||
// to come back (still well inside egui's 0.3 s multi-click window at 1/60 s a frame), so
|
||
// a loaded machine doesn't make the later clicks land on a page with no text yet.
|
||
for _ in 0..8 {
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||
h.run_steps(1);
|
||
}
|
||
}
|
||
h.run_steps(3);
|
||
// Under load the mark can land a few frames later; wait for it (egui's clock is fixed per
|
||
// frame, so waiting can't turn the clicks into a different run).
|
||
for _ in 0..200 {
|
||
let app = h.state();
|
||
if app.session.get(app.views[0].id).is_some_and(|d| !d.info.annotations.is_empty()) && !app.render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
h.run_steps(1);
|
||
}
|
||
let app = h.state();
|
||
let doc = app.session.get(app.views[0].id).unwrap();
|
||
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();
|
||
assert_eq!(marks, [("Highlight", 1)], "one highlight, on the double-clicked word");
|
||
assert!(app.views[0].selected_text().is_none());
|
||
}
|
||
|
||
/// Press at `from`, move to `to` and release there.
|
||
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
|
||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
for k in 1..=4 {
|
||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
}
|
||
|
||
/// Click at `pos` with Shift held.
|
||
fn shift_click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) {
|
||
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
|
||
h.hover_at(pos);
|
||
h.run_steps(1);
|
||
for pressed in [true, false] {
|
||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
|
||
}
|
||
h.run_steps(1);
|
||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||
h.run_steps(1);
|
||
}
|
||
|
||
/// Issue #527: ⇧-click extends the text selection to the click, keeping its anchor.
|
||
#[test]
|
||
fn shift_click_extends_the_selection_from_its_anchor() {
|
||
let (mut h, _) = harness(|_| {});
|
||
let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen");
|
||
// Drag over "quick" (glyphs 4..=8).
|
||
let (q, k) = (glyph(&h, 4), glyph(&h, 8));
|
||
drag(&mut h, q, k);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||
h.run_steps(60);
|
||
// ⇧-click on the "n" of "brown": the selection runs from the anchor to it.
|
||
let n = glyph(&h, 14);
|
||
shift_click(&mut h, n);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick brown"));
|
||
h.run_steps(60);
|
||
// ⇧-click before the anchor: the anchor stays, the selection now runs back to the click.
|
||
let t = glyph(&h, 0);
|
||
shift_click(&mut h, t);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The q"));
|
||
h.run_steps(60);
|
||
// A plain click still clears it.
|
||
click(&mut h, n);
|
||
assert_eq!(h.state().views[0].selected_text(), None);
|
||
h.run_steps(60);
|
||
// ⇧-click with nothing selected selects nothing.
|
||
shift_click(&mut h, n);
|
||
assert_eq!(h.state().views[0].selected_text(), None);
|
||
}
|
||
|
||
/// Issue #1024: multi-click selection triggers immediately on pointer press, before release.
|
||
#[test]
|
||
fn multiclick_selects_on_pointer_press_before_release() {
|
||
let (mut h, at) = harness(|_| {});
|
||
// First click: press and release.
|
||
click(&mut h, at);
|
||
// Second click: press and HOLD (do not release).
|
||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "word highlights on press");
|
||
// Release the second click: still selected.
|
||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "word stays selected on release");
|
||
|
||
// Third click: press and HOLD.
|
||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line highlights on press");
|
||
// Release third click.
|
||
h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox"), "line stays selected on release");
|
||
}
|
||
|
||
/// `n` pages of 300×200, each ending with one line of Helvetica text near its bottom edge.
|
||
fn bottom_lines(n: usize) -> Vec<u8> {
|
||
let mut objects = String::new();
|
||
let mut kids = Vec::new();
|
||
for k in 0..n {
|
||
let (page, contents) = (4 + 2 * k, 5 + 2 * k);
|
||
kids.push(format!("{page} 0 R"));
|
||
let content = format!("BT /F1 14 Tf 20 20 Td (Page {} ends with this line) Tj ET", k + 1);
|
||
objects.push_str(&format!(
|
||
"{page} 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents {contents} 0 R /Resources << /Font << /F1 3 0 R >> >> >> endobj\n\
|
||
{contents} 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n",
|
||
content.len()
|
||
));
|
||
}
|
||
format!(
|
||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [{}] /Count {n} >> endobj\n\
|
||
3 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj\n{objects}trailer << /Root 1 0 R >>\n%%EOF",
|
||
kids.join(" ")
|
||
)
|
||
.into_bytes()
|
||
}
|
||
|
||
/// Press at `from` and move to `to`, keeping the button down.
|
||
fn press_and_move(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
|
||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||
h.run_steps(1);
|
||
for k in 1..=4 {
|
||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
}
|
||
|
||
/// One wheel event, with time to scroll (egui spreads a wheel line over a few frames).
|
||
fn wheel(h: &mut Harness<'static, PdfCraftApp>, unit: MouseWheelUnit, dy: f32) {
|
||
h.event(Event::MouseWheel { unit, delta: egui::vec2(0.0, dy), phase: TouchPhase::Move, modifiers: Modifiers::NONE });
|
||
h.run_steps(10);
|
||
}
|
||
|
||
/// The top edge of `page` on screen.
|
||
fn page_top(h: &Harness<'static, PdfCraftApp>, page: usize) -> f32 {
|
||
h.state().views[0].page_screen_rect(page).expect("page on screen").min.y
|
||
}
|
||
|
||
/// Issue #721: selecting near the bottom of a page with the button held, the wheel keeps
|
||
/// scrolling while the pointer crosses the gap onto the next page. The selection stays on its
|
||
/// own page (a selection can't span pages yet).
|
||
#[test]
|
||
fn a_selection_drag_keeps_scrolling_over_the_gap_and_the_next_page() {
|
||
let (mut h, _) = harness_with(bottom_lines(3), |app| {
|
||
app.set_option("layout", "continuous").unwrap();
|
||
app.set_option("zoom", "200").unwrap();
|
||
});
|
||
let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen");
|
||
// "ends" (glyphs 7..=10) in "Page 1 ends with this line".
|
||
let (from, to) = (glyph(&h, 7), glyph(&h, 10));
|
||
press_and_move(&mut h, from, to);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("ends"));
|
||
let (mut over_gap, mut over_next) = (false, false);
|
||
for _ in 0..100 {
|
||
let before = page_top(&h, 0);
|
||
wheel(&mut h, MouseWheelUnit::Line, -0.1);
|
||
assert!(page_top(&h, 0) < before - 0.5, "the wheel scrolls during the drag (over the gap yet: {over_gap})");
|
||
let view = &h.state().views[0];
|
||
let on = |page| view.page_screen_rect(page).is_some_and(|r| r.contains(to));
|
||
over_gap |= !on(0) && !on(1);
|
||
over_next = on(1);
|
||
if over_next {
|
||
break;
|
||
}
|
||
}
|
||
assert!(over_gap && over_next, "the pointer crossed the gap ({over_gap}) onto the next page ({over_next})");
|
||
let held = h.state().views[0].selected_text().expect("still selected");
|
||
assert!(held.starts_with("ends") && "Page 1 ends with this line".contains(held.as_str()), "the selection stays on page 1: {held:?}");
|
||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].selected_text(), Some(held), "releasing keeps the selection");
|
||
}
|