Files
pdfcraft/crates/ui-egui/tests/it/text_select.rs
T
aiongg 64e0868b84 feat(ui): menus from craft-menu, native on macOS (#4752)
* feat(ui): menus from craft-menu, native on macOS

One menu definition (crates/ui-egui/src/menus.rs) built from the command
registry drives the native macOS menu bar (craft-menu's muda backend) and
an in-window egui bar on Windows, Linux and the web, which takes the place
of the "Menu" button at the start of the top bar.

- File, Edit, View, Sign, Window and Help, with separators and submenus.
  Edit gains Cut, Paste, Delete and Select All (they act as their keys
  do) and a Pages submenu; View gains a Tools submenu built from the All
  tools groups; Sign holds Fill & Sign and the certificate commands;
  Window lists the open documents.
- Toggles are check items and the page layouts, side panels and themes
  radio items, with their state from the app.
- File > Open Recent lists the recent files (file.open_recent with an
  index).
- A menu click and its shortcut run the same dispatch, including the
  form-field deferral (#166) and macOS Cmd+W closing the window (#869).
  Dialogs and the palette grey the menus through craft-menu's gate.
- Quit goes through craft-quit, so the unsaved-changes prompt runs.
- craft-ui, craft-quit, craft-menu and craft-keymap pinned to
  storytold/craft-libs 70ade1fb.

Menu state and dispatch follow #2610 by @hajdukste; the menu tree and the
view and panel items follow #3870 by @RivanJarjes.

* test(ui): the menu model, native dispatch and the in-window bar

- Unit tests for the menu definition: every registry menu command is
  listed once with its registry shortcut; check and radio items follow
  the app; a dialog greys document commands and drops their clicks; the
  recent files and open documents are listed once each; a native menu
  click (through a fake craft-menu backend) runs its command once.
- The kittests that opened the old "Menu" button use the menu bar, and
  the ones that look up a label the bar's titles share (Edit, File,
  Sign) skip the bar.
- The in-window bar takes its titles' width, so the mode tabs, search
  and buttons keep their place in the top bar.
- Edit menu clicks that stand for keys (Copy, Cut, Delete, Select All)
  reach this frame's input from `logic`, where test hosts see them too.

* test(ui): the language switch test opens File in the menu bar

Translated titles and rows carry their mnemonic ("ファイル(F)", "開く(O)…").

* feat(ui): the menu bars run Quit themselves, the bar shrinks to its titles, and AppKit's window list stays off

craft-libs main edc41f6 (craft-libs#354): MenuBarUi runs Quit and Minimize, shrink_to_fit sizes the in-window bar, Key::parse reads the registry's arrow and minus glyphs, and mac::install_with(.., window_list(false)) keeps the Window menu to PdfCraft's own document list.

* fix(ui): the menu command filter is an if, for clippy

* test(ui): drop a needless borrow in the edit-tab helper

* test(ui): two tests follow the menu bar

The tests were wrong, not the app. The Preferences language test clicked the old Menu button; it opens Edit in the menu bar and its Preferences row. The prepare Actions test counted the Edit labels: the narrower menu bar leaves room for the Edit tab, so the rows' Edit buttons come third and fourth.
2026-10-26 00:57:46 -04:00

652 lines
29 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.
//! 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> {
text_pdf("BT /F1 14 Tf 20 150 Td (The quick brown fox) Tj 0 -24 Td (jumps over the dog) Tj ET")
}
fn text_pdf(content: &str) -> Vec<u8> {
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 /Encoding /WinAnsiEncoding >> 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
});
// Wait for the text layer itself (#1277), not for a fixed second.
crate::support::wait_until(&mut h, "the page's text layer", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
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 later; wait for it (egui's clock is fixed per frame, so
// waiting can't turn the clicks into a different run). By state, not for a fixed second
// (#1277).
crate::support::wait_until(&mut h, "the highlight and the page after it", |app| {
app.session.get(app.views[0].id).is_some_and(|d| !d.info.annotations.is_empty()) && !app.render_pending()
});
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");
}
// Issue #1225: page-text Copy belongs in both menus and observes PDF permissions.
fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool {
h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == want))
}
/// Check that both the Edit menu and the context menu copy the selected page text.
#[test]
fn selected_text_copy_edit_and_context_menus() {
use egui_kittest::kittest::Queryable;
let (mut h, at) = harness(|_| {});
click(&mut h, at);
click(&mut h, at);
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand;
h.run_steps(2); // Hide floating Copy so this test can only reach menu entries.
crate::support::open_menu(&mut h, "Edit");
crate::support::menu_row(&h, "Copy").click();
let mut did_copy = false;
for _ in 0..4 {
h.step();
did_copy |= copied(&h, "quick");
}
assert!(did_copy, "Edit Copy emits the selected text");
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand;
h.run_steps(2); // Hide the floating bar: the remaining Copy must be in the context menu.
h.event(Event::PointerMoved(at));
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
h.run_steps(1);
}
h.run_steps(2);
h.get_by_label_contains("Copy").click();
let mut did_copy = false;
for _ in 0..4 {
h.step();
did_copy |= copied(&h, "quick");
}
assert!(did_copy, "context Copy emits the selected text");
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
}
#[test]
fn escape_closes_an_open_menu_before_it_clears_the_selection() {
// #3535: one Escape closed the menu and dropped the selection with it.
let (mut h, at) = harness(|_| {});
click(&mut h, at);
click(&mut h, at);
h.run_steps(2);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
crate::support::open_menu(&mut h, "View");
assert!(egui::Popup::is_any_open(&h.ctx), "the menu is open");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(!egui::Popup::is_any_open(&h.ctx), "Escape closes the menu");
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"), "and keeps the selection");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!(h.state().views[0].selected_text(), None, "a second Escape clears it");
}
#[test]
fn selected_text_copy_command_requires_selection() {
let (mut h, at) = harness(|_| {});
assert!(!h.state_mut().execute("edit.copy"));
click(&mut h, at);
click(&mut h, at);
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert!(copied(&h, "quick"));
let mut app = PdfCraftApp::new();
assert!(!app.execute("edit.copy"));
}
#[test]
fn selected_text_copy_floating_button_honours_permissions() {
use egui_kittest::kittest::Queryable;
for password in ["pw", "owner"] {
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(two_lines())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "pw",
owner_password: "owner",
permissions: -1 ^ 0b1_0000,
encrypt_metadata: true,
seed: [4; 32],
})
.unwrap();
let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap();
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("no-copy.pdf", None, bytes).unwrap();
app.submit_password(Some(password.into()));
app.set_option("left", "closed").unwrap();
app
});
let mut loaded = false;
for _ in 0..200 {
h.run_steps(2);
if h.state_mut().views[0].select_all() {
loaded = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(loaded);
h.run_steps(2);
h.get_by_label("Copy").click();
let mut did_copy = false;
for _ in 0..4 {
h.step();
did_copy |= copied(&h, "The quick brown fox\njumps over the dog");
}
assert_eq!(did_copy, password == "owner", "floating Copy with {password} password");
assert_eq!(h.state_mut().execute("edit.copy"), password == "owner");
}
}
#[test]
fn selected_text_copy_preserves_unicode_and_page_clipboard() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.system_clipboard = false;
app.open_bytes("unicode.pdf", None, text_pdf(r"BT /F1 14 Tf 20 150 Td (caf\351) Tj ET")).unwrap();
app
});
let mut loaded = false;
for _ in 0..200 {
h.run_steps(2);
if h.state_mut().views[0].select_all() {
loaded = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
assert!(loaded);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("café"));
assert!(h.state_mut().execute("edit.copy"));
h.step();
assert!(copied(&h, "café"));
assert!(h.state().page_clipboard.is_none());
assert!(h.state_mut().execute("page.organize"));
h.run_steps(2);
assert!(!h.state_mut().execute("edit.copy"), "Organize retains page clipboard ownership");
assert!(h.state_mut().execute("page.copy"));
assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![0]));
}
/// Check that a focused search input owns Copy and the selected page text does not also consume it.
#[test]
fn selected_text_copy_keeps_focused_input_clipboard_ownership() {
let (mut h, at) = harness(|_| {});
click(&mut h, at);
click(&mut h, at);
h.state_mut().views[0].open_find();
h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into();
h.run_steps(3);
h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input")));
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A);
h.run_steps(2);
h.event(Event::Copy);
let mut widget_copy = false;
let mut page_copy = false;
for _ in 0..4 {
h.step();
widget_copy |= copied(&h, "widget café");
page_copy |= copied(&h, "quick");
}
assert!(widget_copy, "the focused text input copies its selected text");
assert!(!page_copy, "page text must not also consume the Copy event");
}
/// `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,
source: egui::MouseWheelSource::Unknown,
});
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");
}
/// One 300×600 page with a line of Helvetica text at its top and another at its bottom.
fn tall_page() -> Vec<u8> {
let content = "BT /F1 14 Tf 20 570 Td (Top line) Tj 0 -550 Td (Bottom line) 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 600] /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()
}
/// Issue #1670: holding a selection drag at the bottom edge of the view scrolls it, and the
/// selection follows the pointer down to text that was off screen.
#[test]
fn a_selection_drag_held_at_the_bottom_edge_scrolls_the_view() {
let (mut h, _) = harness_with(tall_page(), |app| {
app.set_option("layout", "continuous").unwrap();
app.set_option("zoom", "200").unwrap();
});
let view = &h.state().views[0];
let viewport = view.viewport_rect();
assert!(view.page_screen_rect(0).expect("page on screen").bottom() > viewport.bottom() + 100.0, "the page must not fit");
// "Top" (glyphs 0..=2).
let (from, to) = (view.glyph_screen_pos(0, 0).expect("glyph on screen"), view.glyph_screen_pos(0, 2).expect("glyph on screen"));
press_and_move(&mut h, from, to);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Top"));
let edge = egui::pos2(to.x, viewport.bottom() - 2.0);
let before = page_top(&h, 0);
h.hover_at(edge);
h.run_steps(120);
assert!(page_top(&h, 0) < before - 100.0, "the view scrolls while the pointer rests at its edge ({before} -> {})", page_top(&h, 0));
let held = h.state().views[0].selected_text().expect("still selected");
// The pointer stays above the "t" of "Top", so the selection ends at the "t" of "Bottom".
assert_eq!(held, "Top line\nBot", "the selection reaches the bottom line");
h.event(Event::PointerButton { pos: edge, 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");
}
/// Click at `at`, then press there again and drag to `to` before releasing: a double-click drag.
fn double_click_drag(h: &mut Harness<'static, PdfCraftApp>, at: Pos2, to: Pos2) {
click(h, at);
press_and_move(h, at, to);
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(1);
}
/// Issue #1685: dragging after a double-click extends the selection a word at a time, and the
/// double-clicked word stays selected whichever way the drag goes.
#[test]
fn a_double_click_drag_extends_by_whole_words_both_ways() {
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");
// "The quick brown fox": the "u" of "quick" is glyph 5, the "o" of "brown" glyph 12.
let (u, o) = (glyph(&h, 5), glyph(&h, 12));
double_click_drag(&mut h, u, o);
let right = h.state().views[0].selected_text();
h.run_steps(60);
double_click_drag(&mut h, o, u);
let left = h.state().views[0].selected_text();
assert_eq!([right.as_deref(), left.as_deref()], [Some("quick brown"); 2], "dragging right, then left");
}
/// Issue #1685: after a triple-click the drag extends a whole line at a time.
#[test]
fn a_triple_click_drag_extends_by_whole_lines() {
let (mut h, at) = harness(|_| {});
// A glyph inside "jumps over the dog", on the second line.
let below = h.state().views[0].glyph_screen_pos(0, 23).expect("glyph on screen");
click(&mut h, at);
double_click_drag(&mut h, at, below);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The quick brown fox\njumps over the dog"));
}
/// Coalesced down/up keeps the same word/line selection as separate press and release passes.
#[test]
fn coalesced_primary_clicks_select_line_then_spaced_click_clears() {
for coalesced in [true, false] {
let (mut h, at) = harness(|_| {});
h.ctx.options_mut(|o| o.max_passes = 1.try_into().unwrap());
let id = h.state().views[0].id;
let saved = h.state().session.get(id).unwrap().saved_bytes();
assert!(h.state().views[0].selected_text().is_none());
assert!(!h.state().session.get(id).unwrap().dirty);
for want in [None, Some("quick"), Some("The quick brown fox")] {
if coalesced {
// Harness::event steps each queued event separately; RawInput keeps this pair together.
h.input_mut().events.extend([true, false].map(|pressed| Event::PointerButton {
pos: at,
button: PointerButton::Primary,
pressed,
modifiers: Modifiers::NONE,
}));
h.step();
} else {
click(&mut h, at);
}
assert_eq!(h.state().views[0].selected_text().as_deref(), want, "coalesced={coalesced}");
}
h.run_steps(60);
if coalesced {
h.input_mut().events.extend([true, false].map(|pressed| Event::PointerButton {
pos: at,
button: PointerButton::Primary,
pressed,
modifiers: Modifiers::NONE,
}));
h.step();
} else {
click(&mut h, at);
}
assert_eq!(h.state().views[0].selected_text(), None, "a spaced click clears either gesture path");
assert_eq!(h.state().views[0].id, id);
let doc = h.state().session.get(id).unwrap();
assert!(!doc.dirty);
assert_eq!((doc.can_undo(), doc.can_redo()), (None, None));
assert_eq!(doc.saved_bytes(), saved);
}
}
/// Review on #2070: a click on the page in full screen waits out the double-click time before
/// it turns the page, and a link clicked meanwhile used to leave that turn pending: the link
/// jumped to page 3, then the page moved on to page 4.
#[test]
fn full_screen_link_click_cancels_the_pending_page_turn() {
let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>";
let four_pages = String::from_utf8(two_lines())
.unwrap()
.replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R 7 0 R 8 0 R] /Count 4")
.replace("/Contents 4 0 R /Resources", "/Annots [9 0 R] /Contents 4 0 R /Resources")
.replace(
"trailer",
&format!(
"6 0 obj {page} endobj\n7 0 obj {page} endobj\n8 0 obj {page} endobj\n9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 280 60] /Border [0 0 0] /Dest [7 0 R /Fit] >> endobj\ntrailer"
),
)
.into_bytes();
let (mut h, _) = harness_with(four_pages, |_| {});
h.state_mut().apply_full_screen(true);
crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen");
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height());
// A click on blank page area, then, within the double-click time, a click on the link.
h.hover_at(at(150.0, 90.0));
h.run_steps(1);
click(&mut h, at(150.0, 90.0));
h.run_steps(3);
h.hover_at(at(240.0, 40.0));
h.run_steps(1);
click(&mut h, at(240.0, 40.0));
h.run_steps(30);
assert_eq!(h.state().views[0].current, 2, "the link's page, and no turn after it");
}
/// Review on #2070: in full screen a click goes forward a page, but a double-click selects the
/// word and stays: the click waits out the double-click time before turning. Turning on the first
/// click's release had sent the second click to the next page. Leaving full screen meanwhile
/// drops the pending turn.
#[test]
fn full_screen_double_click_selects_a_word_and_stays_on_the_page() {
// The same page twice.
let two_pages = String::from_utf8(two_lines())
.unwrap()
.replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R] /Count 2")
.replace(
"trailer",
"6 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\ntrailer",
)
.into_bytes();
let (mut h, _) = harness_with(two_pages, |_| {});
h.state_mut().apply_full_screen(true);
crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending());
let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer");
h.hover_at(at);
h.run_steps(1);
// A double-click selects "quick" and stays on page 1, also once the double-click time is over.
click(&mut h, at);
click(&mut h, at);
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
h.run_steps(30);
assert_eq!(h.state().views[0].current, 0, "the double-click didn't turn the page");
// A single click turns it once the double-click time is over.
// Entering full screen again clears the selection.
h.state_mut().apply_full_screen(false);
h.state_mut().apply_full_screen(true);
h.run_steps(30);
click(&mut h, at);
h.run_steps(30);
assert_eq!(h.state().views[0].current, 1, "a click goes forward");
// A click right before leaving full screen doesn't turn the page after it.
h.state_mut().views[0].go_to_page(0);
h.run_steps(30);
click(&mut h, at);
h.state_mut().apply_full_screen(false);
h.run_steps(30);
assert_eq!(h.state().views[0].current, 0, "no page turn after leaving");
}