mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
* fix: recognize paragraph drafts as unsaved work (Issue #811) * fix(color): honour the /Lab colour space /WhitePoint (#1203) The old path fed every Lab value to the bundled ICC transform as if the space's white point were D50, so a /Lab space with any other /WhitePoint (e.g. D65 [0.9505 1 1.089]) rendered off by several levels per channel and a D65 blue came out 45 levels too red. Map L*a*b* to XYZ in the space's own white coordinates (ISO 32000-2 §8.6.5.4), Bradford-adapt it to D65 and convert to sRGB directly, as Poppler does. Missing or degenerate white points fall back to a flat white and hostile components are clamped without panicking. Add a render regression that checks the five reported flat patches of the D65 white-point fixture against their CIE sRGB values. * Lab: map normalised Indexed palette components onto L*/Range; drop unused LAB.icc An /Indexed palette over Lab passes its entries as byte/255 (manual_scale), which the new Lab conversion read as raw L*a*b* and drew near black. Map them onto L* 0..100 and the a*/b* /Range, with a regression test. Record the patch as hayro-interpret (25) in vendor/README.md, and remove LAB.icc (no longer referenced) with its ATTRIBUTION, NOTICE and assets README entries. Co-Authored-By: Claude Opus 5.5 <[email protected]> * Main gates: rustfmt tests/viewer.rs; a type alias for the refresh-steps array (clippy type_complexity) Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: Brandon Thomas <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
889 lines
38 KiB
Rust
889 lines
38 KiB
Rust
//! Viewer conveniences: close all, revert, fit height, view history, select all, find options,
|
||
//! cover page, a scroll position per tab.
|
||
|
||
use egui_kittest::Harness;
|
||
use egui_kittest::kittest::Queryable;
|
||
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
|
||
|
||
/// `n` pages of 200×300; page i says "Page i+1" plus "page" and "Pages" for find tests.
|
||
fn fixture(n: usize) -> Vec<u8> {
|
||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 200 300] >>", kids.join(" ")));
|
||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||
for i in 0..n {
|
||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * i));
|
||
let body = format!("BT /F1 18 Tf 20 150 Td (Page {} pages PAGE) Tj ET", i + 1);
|
||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||
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.open_bytes("a.pdf", None, fixture(5)).unwrap();
|
||
app.open_bytes("b.pdf", None, fixture(2)).unwrap();
|
||
app
|
||
});
|
||
for _ in 0..40 {
|
||
h.run_steps(2);
|
||
if !h.state().render_pending() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
h
|
||
}
|
||
|
||
#[test]
|
||
fn close_all_asks_only_for_changed_documents() {
|
||
let mut h = harness();
|
||
// Change the active document (b.pdf).
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
|
||
h.state_mut().execute("file.close_all");
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views.len(), 1, "the clean document closed at once");
|
||
assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::All), "b.pdf asks to be saved");
|
||
h.get_by_label_contains("Save changes");
|
||
}
|
||
|
||
#[test]
|
||
fn revert_after_confirming() {
|
||
let mut h = harness();
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0] });
|
||
assert!(h.state_mut().execute("file.revert"));
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, Some(Dialog::Revert));
|
||
h.get_all_by_label("Revert").last().expect("the button").click();
|
||
h.run_steps(3);
|
||
let s = h.state();
|
||
let d = s.session.get(s.views[1].id).unwrap();
|
||
assert_eq!((d.info.pages.len(), d.dirty), (2, false));
|
||
}
|
||
|
||
#[test]
|
||
fn view_history_select_all_and_find_options() {
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.run_steps(2);
|
||
h.state_mut().views[0].go_to_page(3);
|
||
h.run_steps(2);
|
||
h.state_mut().views[0].go_to_page(1);
|
||
h.run_steps(2);
|
||
assert!(h.state_mut().views[0].view_history(false));
|
||
assert_eq!(h.state().views[0].current, 3, "previous view");
|
||
assert!(h.state_mut().views[0].view_history(false));
|
||
assert_eq!(h.state().views[0].current, 0);
|
||
assert!(h.state_mut().views[0].view_history(true));
|
||
assert_eq!(h.state().views[0].current, 3, "next view");
|
||
// Select all on the current page (once its text is known).
|
||
for _ in 0..40 {
|
||
h.run_steps(2);
|
||
if h.state_mut().views[0].select_all() {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 4 pages PAGE"));
|
||
h.state_mut().views[0].select_text(3, 0, 0);
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("Page 4 pages PAGE"), "the viewer shortcut still selects text");
|
||
assert!(h.state().views[0].selected.is_empty(), "the viewer shortcut doesn't select pages");
|
||
// Find: "page" matches three times a page; whole words, case-sensitive narrows it.
|
||
h.state_mut().views[0].open_find();
|
||
h.state_mut().views[0].find.as_mut().unwrap().query = "page".into();
|
||
h.state_mut().views[0].rerun_find();
|
||
// Find runs in the background: wait for it by a deadline, not a frame count, which a loaded
|
||
// machine (the whole workspace testing at once) can use up before the last page is searched.
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||
while std::time::Instant::now() < deadline && h.state().views[0].find.as_ref().unwrap().matches.len() < 15 {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 15);
|
||
{
|
||
let f = h.state_mut().views[0].find.as_mut().unwrap();
|
||
f.whole_words = true;
|
||
f.case_sensitive = true;
|
||
}
|
||
h.state_mut().views[0].rerun_find();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 0, "\"page\" alone, lower case, appears nowhere");
|
||
h.state_mut().views[0].find.as_mut().unwrap().query = "PAGE".into();
|
||
h.state_mut().views[0].rerun_find();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 5);
|
||
}
|
||
|
||
#[test]
|
||
fn find_shortcuts_advance_and_retreat_matches() {
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.run_steps(2);
|
||
h.state_mut().views[0].open_find();
|
||
h.state_mut().views[0].find.as_mut().unwrap().query = "page".into();
|
||
h.state_mut().views[0].rerun_find();
|
||
// Find runs in the background: wait by a deadline, not a frame count, which a loaded machine
|
||
// can use up before the last page is searched.
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||
while std::time::Instant::now() < deadline {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 {
|
||
break;
|
||
}
|
||
}
|
||
let n = h.state().views[0].find.as_ref().unwrap().matches.len();
|
||
assert_eq!(n, 15);
|
||
// Surrender text edit focus so canvas shortcut handler receives key events.
|
||
h.state_mut().views[0].find.as_mut().unwrap().focus = false;
|
||
h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new("find-input")));
|
||
h.run_steps(2);
|
||
let start = h.state().views[0].find.as_ref().unwrap().current.unwrap();
|
||
// ⌘G advances to next match.
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::G);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + 1) % n));
|
||
// ⇧⌘G retreats to previous match.
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some(start));
|
||
// ⇧⌘G again wraps backwards.
|
||
h.key_press_modifiers(egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, egui::Key::G);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, Some((start + n - 1) % n));
|
||
}
|
||
|
||
#[test]
|
||
fn marking_while_searching_does_not_jitter_or_reset_search_state() {
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.run_steps(2);
|
||
h.state_mut().views[0].open_find();
|
||
h.state_mut().views[0].find.as_mut().unwrap().query = "page".into();
|
||
h.state_mut().views[0].rerun_find();
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||
while std::time::Instant::now() < deadline {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
if h.state().views[0].find.as_ref().unwrap().matches.len() >= 15 {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 15);
|
||
let current_before = h.state().views[0].find.as_ref().unwrap().current;
|
||
assert!(current_before.is_some());
|
||
// Clear any pending goto from initial find
|
||
h.run_steps(2);
|
||
assert!(h.state().views[0].goto.is_none());
|
||
|
||
// User draws or marks an annotation on page 0
|
||
let shape = pdfcraft_engine::Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 20.0]]] };
|
||
let stroke = pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||
page: 0,
|
||
style: pdfcraft_engine::Style::default_for(&shape),
|
||
shape,
|
||
contents: String::new(),
|
||
author: "tester".into(),
|
||
});
|
||
h.state_mut().apply_edit(stroke);
|
||
|
||
// Right after applying the edit, search matches and current match must still be valid
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, current_before, "applying an annotation must not wipe current search match");
|
||
|
||
// Let the canvas process the reload
|
||
let reload_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||
while std::time::Instant::now() < reload_deadline {
|
||
h.run_steps(2);
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
assert!(h.state().views[0].goto.is_none(), "page reload after annotation must not set goto / trigger display jitter");
|
||
if h.state().views[0].glyph_screen_pos(0, 0).is_some() {
|
||
break;
|
||
}
|
||
}
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().current, current_before);
|
||
assert_eq!(h.state().views[0].find.as_ref().unwrap().matches.len(), 15);
|
||
}
|
||
|
||
#[test]
|
||
fn layouts_fit_height_labels_and_system_theme() {
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
// Fit height: the page's height fills the view (less the margins).
|
||
h.state_mut().views[0].fit = Fit::Height;
|
||
h.run_steps(4);
|
||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||
let vp = h.state().views[0].viewport_rect();
|
||
assert!(r.height() > vp.height() * 0.85 && r.top() >= vp.top() && r.bottom() <= vp.bottom() + 1.0, "page {r:?} in {vp:?}");
|
||
// Two-page view scrolls continuously; a cover puts page 1 alone on the right.
|
||
h.state_mut().views[0].fit = Fit::Width;
|
||
h.state_mut().views[0].layout = PageLayout::TwoUp;
|
||
h.run_steps(4);
|
||
let (a, b) = (h.state().views[0].page_screen_rect(0).unwrap(), h.state().views[0].page_screen_rect(1).unwrap());
|
||
assert!((a.top() - b.top()).abs() < 1.0 && b.left() > a.right(), "side by side");
|
||
h.state_mut().views[0].cover = true;
|
||
h.run_steps(4);
|
||
let (a, b, c) = (
|
||
h.state().views[0].page_screen_rect(0).unwrap(),
|
||
h.state().views[0].page_screen_rect(1).unwrap(),
|
||
h.state().views[0].page_screen_rect(2).unwrap(),
|
||
);
|
||
assert!(a.left() > vp.center().x - 1.0, "the cover sits on the right");
|
||
assert!(b.top() > a.bottom() && (b.top() - c.top()).abs() < 1.0, "then pairs 2–3");
|
||
// Page labels in the page box.
|
||
h.state_mut().apply_edit(pdfcraft_engine::Edit::NumberPages {
|
||
from: 0,
|
||
to: 1,
|
||
style: pdfcraft_engine::LabelStyle::LowerRoman,
|
||
prefix: String::new(),
|
||
first: 1,
|
||
});
|
||
h.run_steps(2);
|
||
let labels: Vec<String> = {
|
||
let s = h.state();
|
||
s.session.get(s.views[0].id).unwrap().info.pages.iter().map(|p| p.label.clone()).collect()
|
||
};
|
||
assert_eq!(labels[1], "ii");
|
||
assert!(h.state_mut().views[0].go_to_typed("ii", &labels));
|
||
assert_eq!(h.state().views[0].current, 1);
|
||
assert!(!h.state_mut().views[0].go_to_typed("xx", &labels));
|
||
// Follow the system theme.
|
||
h.state_mut().set_option("theme", "system").unwrap();
|
||
assert_eq!(h.state().theme_preference, pdfcraft_ui_egui::theme::ThemePreference::System);
|
||
}
|
||
|
||
#[test]
|
||
fn two_page_view_steps_a_spread_at_a_time() {
|
||
// #70: ⌘→ went to the right-hand page of the same spread, so the view never moved.
|
||
use egui::{Key, Modifiers};
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().views[0].fit = Fit::Width;
|
||
h.state_mut().views[0].layout = PageLayout::TwoUp;
|
||
h.run_steps(4);
|
||
let step = |h: &mut Harness<'static, PdfCraftApp>, key| {
|
||
h.key_press_modifiers(Modifiers::COMMAND, key);
|
||
h.run_steps(4);
|
||
h.state().views[0].current
|
||
};
|
||
let pages: Vec<usize> = [Key::ArrowRight, Key::ArrowRight, Key::ArrowRight, Key::ArrowLeft, Key::ArrowLeft].map(|k| step(&mut h, k)).into();
|
||
assert_eq!(pages, [2, 4, 4, 2, 0], "spreads 1–2, 3–4, 5");
|
||
// With a cover page the spreads are 1, 2–3, 4–5.
|
||
h.state_mut().views[0].cover = true;
|
||
h.run_steps(4);
|
||
let pages: Vec<usize> = [Key::ArrowRight, Key::ArrowRight, Key::ArrowLeft, Key::ArrowLeft].map(|k| step(&mut h, k)).into();
|
||
assert_eq!(pages, [1, 3, 1, 0]);
|
||
}
|
||
|
||
#[test]
|
||
fn damaged_files_say_they_were_repaired() {
|
||
// No cross-reference table: the file is reconstructed.
|
||
let damaged = b"%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 200 200] >> endobj
|
||
trailer << /Root 1 0 R >>
|
||
%%EOF"
|
||
.to_vec();
|
||
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| {
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("damaged.pdf", None, damaged.clone()).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
h.get_by_label_contains("This file was damaged and has been repaired.");
|
||
h.get_by_label("Details").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().dialog, Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced)));
|
||
h.get_by_label("Repair log");
|
||
}
|
||
|
||
#[test]
|
||
fn initial_view_is_edited_and_honoured_on_open() {
|
||
use pdfcraft_engine::{InitialLayout, Magnification, Navigation};
|
||
let mut h = harness();
|
||
h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::InitialView));
|
||
h.run_steps(2);
|
||
h.get_by_label("Open to page");
|
||
{
|
||
let (_, v) = h.state_mut().view_draft.as_mut().expect("seeded");
|
||
v.navigation = Navigation::Bookmarks;
|
||
v.layout = InitialLayout::TwoUpCoverPage;
|
||
v.magnification = Magnification::FitWidth;
|
||
v.page = 1;
|
||
v.language = Some("de-DE".into());
|
||
}
|
||
h.run_steps(2);
|
||
// The dialog's height settles a frame after it opens; egui centers from the previous size.
|
||
h.run_steps(2);
|
||
h.get_by_label("OK").click();
|
||
h.run_steps(3);
|
||
let s = h.state();
|
||
let id = s.views[s.active.unwrap()].id;
|
||
let v = s.session.get(id).unwrap().initial_view();
|
||
assert_eq!((v.navigation, v.layout, v.page, v.language.as_deref()), (Navigation::Bookmarks, InitialLayout::TwoUpCoverPage, 1, Some("de-DE")));
|
||
assert_eq!(s.session.get(id).unwrap().can_undo(), Some("Change document properties"));
|
||
// Opening the saved file follows it.
|
||
let bytes = s.session.save_bytes(id).unwrap();
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("again.pdf", None, bytes.to_vec()).unwrap();
|
||
let view = &app.views[0];
|
||
assert_eq!((view.current, view.cover, app.right), (1, true, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)));
|
||
}
|
||
|
||
fn drag(h: &mut Harness<'static, PdfCraftApp>, a: egui::Pos2, b: egui::Pos2) {
|
||
h.hover_at(a);
|
||
h.run_steps(1);
|
||
h.drag_at(a);
|
||
h.run_steps(1);
|
||
for k in 1..=4 {
|
||
h.hover_at(a + (b - a) * (k as f32 / 4.0));
|
||
h.run_steps(1);
|
||
}
|
||
h.drop_at(b);
|
||
h.run_steps(3);
|
||
}
|
||
|
||
#[test]
|
||
fn marquee_zoom_and_snapshot() {
|
||
let mut h = harness();
|
||
let i = h.state().active.unwrap();
|
||
// Text "Page 2 pages PAGE" sits at y 150 (of 300) from x 20.
|
||
let r = h.state().views[i].page_screen_rect(0).expect("on screen");
|
||
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
|
||
// Snapshot of the text line.
|
||
h.state_mut().system_clipboard = false;
|
||
assert!(h.state_mut().execute("edit.snapshot"));
|
||
drag(&mut h, at(15.0, 175.0), at(180.0, 140.0));
|
||
let (w, hgt, px) = h.state().last_snapshot.clone().expect("a snapshot");
|
||
assert!(w > 50 && hgt > 10, "{w} × {hgt}");
|
||
assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] < 100), "the text is in it");
|
||
// Marquee zoom on a small area zooms in, centred on it.
|
||
let before = h.state().views[i].zoom;
|
||
h.state_mut().set_option("quick", "marquee-zoom").unwrap();
|
||
drag(&mut h, at(20.0, 160.0), at(60.0, 140.0));
|
||
h.run_steps(4);
|
||
let after = h.state().views[i].zoom;
|
||
assert!(after > before * 2.0, "{before} → {after}");
|
||
}
|
||
|
||
#[test]
|
||
fn fit_visible_zooms_to_the_content_width() {
|
||
let mut h = harness();
|
||
let i = h.state().active.unwrap();
|
||
let before = h.state().views[i].zoom;
|
||
assert!(h.state_mut().execute("view.fit_visible"));
|
||
for _ in 0..4 {
|
||
h.run_steps(2);
|
||
}
|
||
let v = &h.state().views[i];
|
||
assert!(v.zoom > before * 1.2, "{before} → {}", v.zoom);
|
||
// The text starts 20 pt in from the page's left; that edge is now near the window's left.
|
||
let r = v.page_screen_rect(v.current).expect("on screen");
|
||
let ink_left = r.left() + 20.0 / 200.0 * r.width();
|
||
let vp = v.viewport_rect();
|
||
assert!((ink_left - vp.left() - 16.0).abs() < 12.0, "ink at {ink_left}, viewport {vp:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn tab_and_window_show_the_document_title_when_asked() {
|
||
use pdfcraft_engine::Edit;
|
||
let mut h = harness();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().window_title, "b.pdf — PdfCraft");
|
||
{
|
||
let s = h.state_mut();
|
||
let id = s.views[s.active.unwrap()].id;
|
||
s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Quarterly report".into() }).unwrap();
|
||
let mut v = s.session.get(id).unwrap().initial_view();
|
||
v.display_title = true;
|
||
s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap();
|
||
}
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().window_title, "Quarterly report — PdfCraft");
|
||
h.get_by_label_contains("Quarterly report");
|
||
}
|
||
|
||
#[test]
|
||
fn a_placeholder_title_leaves_the_file_name_on_the_tab_and_window() {
|
||
// A browser's "Save as PDF" of a blank page or pop-up titles the document "about:blank"
|
||
// and asks viewers to show the title: the file name says more.
|
||
use pdfcraft_engine::Edit;
|
||
let mut h = harness();
|
||
{
|
||
let s = h.state_mut();
|
||
let id = s.views[s.active.unwrap()].id;
|
||
s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "about:blank".into() }).unwrap();
|
||
let mut v = s.session.get(id).unwrap().initial_view();
|
||
v.display_title = true;
|
||
s.session.apply(id, Edit::SetInitialView(Box::new(v))).unwrap();
|
||
}
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().window_title, "b.pdf — PdfCraft");
|
||
assert!(h.query_by_label_contains("about:blank").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn an_earlier_revision_opens_from_document_properties() {
|
||
use pdfcraft_engine::Edit;
|
||
let mut h = harness();
|
||
{
|
||
let s = h.state_mut();
|
||
let id = s.views[s.active.unwrap()].id;
|
||
s.session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Second".into() }).unwrap();
|
||
let bytes = s.session.save_bytes(id).unwrap();
|
||
s.open_bytes("updated.pdf", None, bytes.to_vec()).unwrap();
|
||
}
|
||
h.run_steps(2);
|
||
h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced));
|
||
h.run_steps(2);
|
||
h.run_steps(2);
|
||
// Let the dialog finish centering before the revision button is clicked.
|
||
h.run_steps(2);
|
||
h.get_by_label("View revision 1").click();
|
||
h.run_steps(3);
|
||
let s = h.state();
|
||
assert!(s.dialog.is_none(), "{:?} {:?}", s.dialog, s.session.get(s.views[s.active.unwrap()].id).unwrap().name);
|
||
let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap();
|
||
assert_eq!(doc.name, "updated (revision 1).pdf");
|
||
assert_eq!(doc.info_value("Title"), None, "as it was before the update");
|
||
}
|
||
|
||
#[test]
|
||
fn advanced_search_lists_results_with_context() {
|
||
let mut h = harness();
|
||
let i = h.state().active.unwrap();
|
||
assert!(h.state_mut().execute("edit.advanced_search"));
|
||
h.run_steps(2);
|
||
h.get_by_label("What word or phrase would you like to search for?");
|
||
h.state_mut().views[i].find.as_mut().unwrap().query = "pages".into();
|
||
for _ in 0..40 {
|
||
h.run_steps(2);
|
||
if h.state().views[i].find.as_ref().is_some_and(|f| f.matches.len() == 2) {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||
}
|
||
h.run_steps(2);
|
||
h.get_by_label("2 instances");
|
||
h.get_by_label("Page 2");
|
||
// A result shows its context; clicking it goes there.
|
||
h.get_by_label_contains("Page 2 pages PAGE").click();
|
||
h.run_steps(3);
|
||
let v = &h.state().views[i];
|
||
assert_eq!((v.find.as_ref().unwrap().current, v.current), (Some(1), 1));
|
||
// Closing the panel moves the search to the find bar.
|
||
h.state_mut().right = None;
|
||
h.run_steps(2);
|
||
assert!(!h.state().views[i].find.as_ref().unwrap().in_panel);
|
||
}
|
||
|
||
#[test]
|
||
fn optimizer_audits_space_usage() {
|
||
let mut h = harness();
|
||
assert!(h.state_mut().execute("optimize.advanced"));
|
||
h.run_steps(2);
|
||
h.get_by_label("Audit space usage…").click();
|
||
h.run_steps(3);
|
||
h.get_by_label("Space Audit");
|
||
h.get_by_label("Content Streams");
|
||
h.get_by_label("Total");
|
||
h.get_by_label("OK").click();
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().dialog, Some(Dialog::Optimize), "back to the optimizer");
|
||
}
|
||
|
||
#[test]
|
||
fn each_document_keeps_its_own_scroll_position() {
|
||
// #189: every document's page view shared one scroll position, so switching tabs showed a
|
||
// document at wherever the other one had been scrolled to (usually back at the top).
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let mut h = harness();
|
||
for v in &mut h.state_mut().views {
|
||
v.fit = Fit::Width;
|
||
}
|
||
let show = |h: &mut Harness<'static, PdfCraftApp>, i: usize| {
|
||
h.state_mut().active = Some(i);
|
||
h.run_steps(4);
|
||
h.state().views[i].current
|
||
};
|
||
show(&mut h, 0);
|
||
h.state_mut().views[0].goto = Some((3, 0.0));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 3, "a.pdf scrolled to page 4");
|
||
// b.pdf opens where it was (the top), and is scrolled to its own last page.
|
||
assert_eq!(show(&mut h, 1), 0);
|
||
h.state_mut().views[1].goto = Some((1, 0.0));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[1].current, 1);
|
||
// Back to a.pdf: still on page 4, and b.pdf still on page 2.
|
||
assert_eq!(show(&mut h, 0), 3);
|
||
assert_eq!(show(&mut h, 1), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_document_opens_at_the_top_after_a_relaunch() {
|
||
// eframe saves egui's memory, scroll offsets included, with the settings and restores it at
|
||
// the next launch, where document ids start again at 1: the first document opened then
|
||
// started at the offset the previous session's first document was left at.
|
||
use pdfcraft_ui_egui::canvas::Fit;
|
||
let mut h = harness();
|
||
for v in &mut h.state_mut().views {
|
||
v.fit = Fit::Width;
|
||
}
|
||
h.state_mut().active = Some(0);
|
||
h.run_steps(4);
|
||
h.state_mut().views[0].goto = Some((3, 0.0));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 3, "a.pdf scrolled to page 4");
|
||
// Quit: eframe writes egui's memory to app.ron, as below, and reads it back at the next launch.
|
||
#[derive(Default)]
|
||
struct Settings(std::collections::HashMap<String, String>);
|
||
impl eframe::Storage for Settings {
|
||
fn get_string(&self, key: &str) -> Option<String> {
|
||
self.0.get(key).cloned()
|
||
}
|
||
fn set_string(&mut self, key: &str, value: String) {
|
||
self.0.insert(key.into(), value);
|
||
}
|
||
fn remove_string(&mut self, key: &str) {
|
||
self.0.remove(key);
|
||
}
|
||
fn flush(&mut self) {}
|
||
}
|
||
let mut settings = Settings::default();
|
||
h.ctx.memory(|m| eframe::set_value(&mut settings, "egui", m));
|
||
let memory: egui::Memory = eframe::get_value(&settings, "egui").expect("egui's memory round-trips");
|
||
// Next launch: a document never seen before, which gets a.pdf's old id.
|
||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
|
||
cc.egui_ctx.memory_mut(|m| *m = memory);
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("language", "en").unwrap();
|
||
app.open_bytes("c.pdf", None, fixture(5)).unwrap();
|
||
app.views[0].fit = Fit::Width;
|
||
app
|
||
});
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].id, pdfcraft_engine::DocId(1));
|
||
assert_eq!(h.state().views[0].current, 0, "c.pdf opens on page 1");
|
||
// It is still a scroll position of its own from then on.
|
||
h.state_mut().views[0].goto = Some((2, 0.0));
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 2);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].current, 2, "c.pdf stays on page 3");
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_and_page_keys_move_through_a_scrolling_document() {
|
||
// #185: in the continuous (default) and two-page views the arrow keys and Page Down / Up did
|
||
// nothing without ⌘.
|
||
use egui::Key;
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().views[0].fit = Fit::Width;
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().views[0].layout, PageLayout::Single);
|
||
assert!(h.state().views[0].continuous(), "the default view scrolls continuously");
|
||
let press = |h: &mut Harness<'static, PdfCraftApp>, key, times: usize| {
|
||
for _ in 0..times {
|
||
h.key_press(key);
|
||
h.run_steps(2);
|
||
}
|
||
h.run_steps(2);
|
||
h.state().views[0].current
|
||
};
|
||
// → / ← turn pages, as in Acrobat
|
||
assert_eq!(press(&mut h, Key::ArrowRight, 2), 2);
|
||
assert_eq!(press(&mut h, Key::ArrowLeft, 1), 1);
|
||
assert_eq!(press(&mut h, Key::ArrowLeft, 1), 0);
|
||
// ↓ scrolls a line at a time, Page Down a screen: count the presses that reach page 2
|
||
let presses = |h: &mut Harness<'static, PdfCraftApp>, key| {
|
||
let mut n = 0;
|
||
while h.state().views[0].current == 0 {
|
||
press(h, key, 1);
|
||
n += 1;
|
||
assert!(n < 500, "{key:?} never reached page 2");
|
||
}
|
||
n
|
||
};
|
||
let lines = presses(&mut h, Key::ArrowDown);
|
||
assert_eq!(press(&mut h, Key::ArrowUp, lines + 1), 0);
|
||
let screens = presses(&mut h, Key::PageDown);
|
||
assert_eq!(press(&mut h, Key::PageUp, screens + 1), 0);
|
||
assert!(screens >= 2 && lines > 2 * screens, "a fit-width page: {lines} lines, {screens} screens");
|
||
// two-page view: → moves a spread
|
||
h.state_mut().views[0].layout = PageLayout::TwoUp;
|
||
h.run_steps(4);
|
||
assert_eq!(press(&mut h, Key::ArrowRight, 1), 2);
|
||
// single-page view keeps Page Down for the next page
|
||
h.state_mut().views[0].layout = PageLayout::Single;
|
||
h.state_mut().views[0].continuous = false;
|
||
h.run_steps(4);
|
||
assert_eq!(press(&mut h, Key::PageDown, 1), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn up_and_down_turn_pages_in_single_page_view() {
|
||
// #273: in single-page view ↓ / ↑ only scrolled, so on a page that fits (or once scrolled to
|
||
// the end) they did nothing, while the wheel turned the page there.
|
||
use egui::Key;
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().views[0].layout = PageLayout::Single;
|
||
h.state_mut().views[0].continuous = false;
|
||
h.state_mut().views[0].fit = Fit::Page;
|
||
h.run_steps(4);
|
||
let press = |h: &mut Harness<'static, PdfCraftApp>, key, times: usize| {
|
||
for _ in 0..times {
|
||
h.key_press(key);
|
||
h.run_steps(2);
|
||
}
|
||
h.run_steps(2);
|
||
h.state().views[0].current
|
||
};
|
||
// A page that fits: each press turns one page.
|
||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 1);
|
||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 2);
|
||
assert_eq!(press(&mut h, Key::ArrowUp, 1), 1);
|
||
assert_eq!(press(&mut h, Key::ArrowUp, 1), 0);
|
||
assert_eq!(press(&mut h, Key::ArrowUp, 1), 0, "nothing before the first page");
|
||
// A page taller than the window scrolls first, then turns at its bottom.
|
||
h.state_mut().views[0].fit = Fit::Width;
|
||
h.run_steps(4);
|
||
let mut lines = 0;
|
||
while press(&mut h, Key::ArrowDown, 1) == 0 {
|
||
lines += 1;
|
||
assert!(lines < 500, "↓ never reached page 2");
|
||
}
|
||
assert!(lines > 2, "↓ scrolled through page 1 before turning ({lines} lines)");
|
||
// ↑ at the top of page 2 goes back to the bottom of page 1, then scrolls up it.
|
||
assert_eq!(press(&mut h, Key::ArrowUp, 1), 0);
|
||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 1, "back at the bottom of page 1");
|
||
assert_eq!(press(&mut h, Key::ArrowUp, 2), 0);
|
||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1");
|
||
}
|
||
|
||
#[test]
|
||
fn v_h_and_space_pick_the_quick_tools() {
|
||
// The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing.
|
||
use egui::{Key, Modifiers};
|
||
use pdfcraft_ui_egui::QuickTool;
|
||
let mut h = harness();
|
||
h.state_mut().active = Some(0);
|
||
h.run_steps(2);
|
||
let press = |h: &mut Harness<'static, PdfCraftApp>, key| {
|
||
h.key_press(key);
|
||
h.run_steps(2);
|
||
h.state().quick_tool
|
||
};
|
||
assert_eq!(press(&mut h, Key::H), QuickTool::Hand);
|
||
assert_eq!(press(&mut h, Key::V), QuickTool::Select);
|
||
// V leaves any other tool too, as the toolbar button does.
|
||
h.state_mut().quick_tool = QuickTool::Crop;
|
||
assert_eq!(press(&mut h, Key::V), QuickTool::Select);
|
||
// Holding Space pans with the Hand for as long as it is held, then gives the tool back.
|
||
h.state_mut().quick_tool = QuickTool::Crop;
|
||
h.key_down(Key::Space);
|
||
h.run_steps(4);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Hand, "Space held");
|
||
h.key_up(Key::Space);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Crop, "Space released");
|
||
// With a modifier the letters are someone else's shortcut.
|
||
h.key_press_modifiers(Modifiers::SHIFT, Key::H);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Crop);
|
||
// They are letters to a text field: typing in the find bar keeps the tool.
|
||
h.state_mut().quick_tool = QuickTool::Select;
|
||
h.state_mut().views[0].open_find();
|
||
h.run_steps(4);
|
||
assert_eq!(press(&mut h, Key::H), QuickTool::Select, "typing H in the find bar");
|
||
h.key_down(Key::Space);
|
||
h.run_steps(2);
|
||
h.key_up(Key::Space);
|
||
h.run_steps(2);
|
||
assert_eq!(h.state().quick_tool, QuickTool::Select, "typing a space in the find bar");
|
||
}
|
||
|
||
/// Like [`fixture`], with each page's size given.
|
||
fn sized_fixture(sizes: &[(u32, u32)]) -> Vec<u8> {
|
||
let n = sizes.len();
|
||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 2 * i)).collect();
|
||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} >>", kids.join(" ")));
|
||
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
|
||
for (i, (w, h)) in sizes.iter().enumerate() {
|
||
objs.push(format!(
|
||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
|
||
5 + 2 * i
|
||
));
|
||
let body = format!("BT /F1 18 Tf 20 150 Td (Page {}) Tj ET", i + 1);
|
||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
|
||
}
|
||
let mut out = b"%PDF-1.7\n".to_vec();
|
||
let mut offs = Vec::new();
|
||
for (i, o) in objs.iter().enumerate() {
|
||
offs.push(out.len());
|
||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||
}
|
||
let x = out.len();
|
||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||
for o in offs {
|
||
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{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||
out
|
||
}
|
||
|
||
#[test]
|
||
fn page_down_steps_every_spread_when_several_fit_on_screen() {
|
||
// #188: in two-page view with a cover page, ⌘Page Down skipped spreads (or stuck) once more
|
||
// than one spread fit on screen: the current page was whichever wholly visible page rounding
|
||
// made "most visible", so the next step started from the wrong spread.
|
||
use egui::{Key, Modifiers};
|
||
use pdfcraft_ui_egui::canvas::{Fit, PageLayout};
|
||
// a printed book: portrait cover and back, landscape two-page spreads between
|
||
let mut sizes = vec![(200, 300)];
|
||
sizes.extend([(400, 300); 10]);
|
||
sizes.push((200, 300));
|
||
for fit in [Fit::Width, Fit::Page, Fit::Height] {
|
||
let bytes = sized_fixture(&sizes);
|
||
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("book.pdf", None, bytes).unwrap();
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h.state_mut().active = Some(0);
|
||
let v = &mut h.state_mut().views[0];
|
||
(v.fit, v.layout, v.cover) = (fit, PageLayout::TwoUp, true);
|
||
h.run_steps(6);
|
||
let mut step = |key| {
|
||
h.key_press_modifiers(Modifiers::COMMAND, key);
|
||
h.run_steps(4);
|
||
h.state().views[0].current
|
||
};
|
||
let down: Vec<usize> = (0..6).map(|_| step(Key::PageDown)).collect();
|
||
assert_eq!(down, [1, 3, 5, 7, 9, 11], "{fit:?}: cover, five spreads, back cover");
|
||
let up: Vec<usize> = (0..6).map(|_| step(Key::PageUp)).collect();
|
||
assert_eq!(up, [9, 7, 5, 3, 1, 0], "{fit:?}");
|
||
}
|
||
}
|
||
|
||
/// Open `sizes` in Fit width, continuous, with the Pages panel showing.
|
||
fn pages_panel_harness(sizes: &[(u32, u32)]) -> Harness<'static, PdfCraftApp> {
|
||
let bytes = sized_fixture(sizes);
|
||
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("cheques.pdf", None, bytes).unwrap();
|
||
app.right = Some(pdfcraft_ui_egui::RightPanel::Pages);
|
||
app
|
||
});
|
||
h.run_steps(6);
|
||
h.state_mut().active = Some(0);
|
||
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
|
||
h.run_steps(6);
|
||
h
|
||
}
|
||
|
||
/// A thumbnail in the Pages panel (the panel lists pages as "Page n" buttons).
|
||
fn thumbnail(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Pos2 {
|
||
h.get_all_by_label(label).find(|n| n.rect().left() > 1000.0).expect("the Pages panel thumbnail").rect().center()
|
||
}
|
||
|
||
/// Press and release `button` at `at`.
|
||
fn press(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2, button: egui::PointerButton) {
|
||
h.hover_at(at);
|
||
h.run_steps(1);
|
||
h.event(egui::Event::PointerButton { pos: at, button, pressed: true, modifiers: Default::default() });
|
||
h.event(egui::Event::PointerButton { pos: at, button, pressed: false, modifiers: Default::default() });
|
||
}
|
||
|
||
#[test]
|
||
fn a_short_page_gone_to_from_the_pages_panel_stays_current() {
|
||
// A short page (a cheque) above a tall one: going to it scrolls it to the top, where the tall
|
||
// page below shows more of itself. The current page, and the Pages panel's highlight, moved
|
||
// to the tall page.
|
||
let mut h = pages_panel_harness(&[(600, 250), (600, 1800), (600, 250), (600, 1800)]);
|
||
for page in [1, 0, 2] {
|
||
let at = thumbnail(&h, &format!("Page {}", page + 1));
|
||
press(&mut h, at, egui::PointerButton::Primary);
|
||
h.run_steps(6);
|
||
assert_eq!(h.state().views[0].current, page, "clicked page {}", page + 1);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn pages_panel_thumbnails_have_a_context_menu() {
|
||
let mut h = pages_panel_harness(&[(200, 300); 4]);
|
||
let at = thumbnail(&h, "Page 3");
|
||
press(&mut h, at, egui::PointerButton::Secondary);
|
||
h.run_steps(2);
|
||
// The page right-clicked becomes the selection the menu acts on.
|
||
assert_eq!(h.state().views[0].target_pages(), vec![2]);
|
||
for item in ["Extract pages", "Cut", "Paste after"] {
|
||
h.get_by_label(item);
|
||
}
|
||
h.get_by_label("Copy").click();
|
||
h.run_steps(3);
|
||
assert_eq!(h.state().page_clipboard.as_ref().map(|c| c.pages.clone()), Some(vec![2]));
|
||
}
|
||
|
||
/// #444, #186: a mouse-wheel notch scrolls as far as the scroll speed preference says;
|
||
/// trackpad (point) scrolling keeps its own speed. The choice is saved.
|
||
#[test]
|
||
fn the_scroll_speed_preference_scales_mouse_wheel_notches() {
|
||
use pdfcraft_ui_egui::scroll_speed::ScrollSpeed;
|
||
let scrolled = |speed: ScrollSpeed, unit: egui::MouseWheelUnit, dy: f32| -> f32 {
|
||
let mut h = harness();
|
||
h.state_mut().scroll_speed = speed;
|
||
h.run_steps(2);
|
||
let i = h.state().active.expect("a document is shown");
|
||
let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[i].page_screen_rect(0).expect("page 1 on screen").min.y;
|
||
let before = top(&h);
|
||
h.event(egui::Event::PointerMoved(h.state().views[i].viewport_rect().center()));
|
||
h.event(egui::Event::MouseWheel { unit, delta: egui::vec2(0.0, -dy), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE });
|
||
// egui spreads a wheel notch over a few frames.
|
||
h.run_steps(30);
|
||
before - top(&h)
|
||
};
|
||
let line = egui::MouseWheelUnit::Line;
|
||
let normal = scrolled(ScrollSpeed::Normal, line, 1.0);
|
||
assert!((normal - ScrollSpeed::Normal.line_points()).abs() < 1.0, "one notch at Normal scrolled {normal}");
|
||
let (slow, fast) = (scrolled(ScrollSpeed::Slow, line, 1.0), scrolled(ScrollSpeed::Fast, line, 1.0));
|
||
assert!((slow * 2.0 - normal).abs() < 1.0 && (fast - normal * 2.0).abs() < 1.0, "slow {slow}, normal {normal}, fast {fast}");
|
||
let point = egui::MouseWheelUnit::Point;
|
||
let (normal_points, fast_points) = (scrolled(ScrollSpeed::Normal, point, 30.0), scrolled(ScrollSpeed::Fast, point, 30.0));
|
||
assert!(normal_points > 0.0 && (normal_points - fast_points).abs() < 1.0, "trackpad: {normal_points} vs {fast_points}");
|
||
let mut app = PdfCraftApp::new();
|
||
app.set_option("scroll_speed", "fast").unwrap();
|
||
assert!(app.set_option("scroll_speed", "warp").unwrap_err().contains("slow, normal, or fast"));
|
||
let mut again = PdfCraftApp::new();
|
||
again.restore(&app.persist());
|
||
assert_eq!(again.scroll_speed, ScrollSpeed::Fast);
|
||
}
|