Files
pdfcraft/crates/ui-egui/tests/it/marks.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

331 lines
13 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.
//! Header & footer, watermark and background dialogs in the real shell (egui_kittest).
use crate::support;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::MarkKind;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
const FIXTURE: &[u8] = b"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
trailer << /Root 1 0 R >>
%%EOF";
fn harness() -> Harness<'static, PdfCraftApp> {
harness_with(FIXTURE.to_vec())
}
fn harness_with(pdf: Vec<u8>) -> 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("doc.pdf", None, pdf.clone()).unwrap();
app
});
h.run_steps(4);
h
}
fn marks(h: &Harness<'static, PdfCraftApp>) -> Vec<MarkKind> {
let s = h.state();
s.session.get(s.views[0].id).unwrap().marks().to_vec()
}
#[test]
fn header_and_footer_dialog_inserts_tokens_and_applies_to_a_subset() {
let mut h = harness();
assert!(!h.state_mut().execute("edit.header_footer.remove"), "nothing to remove yet");
assert!(h.state_mut().execute("edit.header_footer"));
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::Marks(MarkKind::HeaderFooter)));
// The center footer box takes the page-number token.
h.state_mut().marks_draft.focused_box = 4;
h.get_by_label("Insert Page Number").click();
h.run_steps(2);
assert_eq!(h.state().marks_draft.hf.text[4], "<<1>>");
h.state_mut().marks_draft.range.subset = pdfcraft_ui_egui::marks::Subset::Odd;
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(h.state().dialog, None);
assert_eq!(marks(&h), [MarkKind::HeaderFooter]);
// Update and Remove are now available.
assert!(h.state_mut().execute("edit.header_footer.update"));
h.run_steps(2);
h.get_by_label("Update Header and Footer");
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(h.state_mut().execute("edit.header_footer.remove"));
assert!(marks(&h).is_empty());
}
#[test]
fn header_and_footer_dialog_draws_in_the_chosen_face() {
// #3754: the dialog offers Helvetica, Times and Courier (it only said "Font: Helvetica"),
// and the face chosen is the one the page's header is drawn in.
let mut h = harness();
assert!(h.state_mut().execute("edit.header_footer"));
h.run_steps(3);
h.get_by_label("Font:");
h.get_by_value("Helvetica").click();
h.run_steps(2);
h.get_by_label("Times").click();
h.run_steps(2);
assert_eq!(h.state().marks_draft.hf.font, pdfcraft_engine::FontFamily::Times);
h.state_mut().marks_draft.hf.text[0] = "Serif".into();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(marks(&h), [MarkKind::HeaderFooter]);
let s = h.state();
let doc = pdfcraft_cos::Document::open(s.session.get(s.views[0].id).unwrap().bytes.clone()).unwrap();
let page = &pdfcraft_model::pages(&doc)[0];
let res = doc.resolve(page.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().cloned().unwrap();
let face = doc.resolve(fonts.get(b"PCTimes").expect("Times is added")).as_dict().cloned().unwrap();
assert_eq!(face.name(b"BaseFont"), Some(&b"Times-Roman"[..]));
}
#[test]
fn header_and_footer_dialog_can_shrink_the_page_content() {
let mut h = harness();
assert!(h.state_mut().execute("edit.bates"));
h.run_steps(3);
assert!(!h.state().marks_draft.hf.shrink, "off by default, as in Acrobat");
h.get_by_label("Shrink the page content to make room for the header and footer").click();
h.run_steps(2);
assert!(h.state().marks_draft.hf.shrink);
h.get_by_label("OK").click();
h.run_steps(4);
assert_eq!(h.state().dialog, None);
assert_eq!(marks(&h), [MarkKind::HeaderFooter]);
}
#[test]
fn watermark_and_background_dialogs() {
let mut h = harness();
h.state_mut().execute("edit.watermark");
h.run_steps(2);
assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for text");
h.get_by_label("Offset from centre");
h.state_mut().marks_draft.wm.text = "DRAFT".into();
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
h.state_mut().execute("edit.background");
h.run_steps(2);
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(marks(&h).len(), 2);
}
#[test]
fn backgrounds_and_watermarks_from_a_file() {
let mut h = harness();
h.state_mut().execute("edit.background");
h.run_steps(2);
support::outside_menus(&h, "File").click();
h.run_steps(2);
h.get_by_label("No file chosen");
assert!(h.query_by(|n| n.label().as_deref() == Some("OK") && n.is_disabled()).is_some(), "OK waits for a file");
// (Browse… opens the system picker; the test supplies the file directly.)
h.state_mut().marks_draft.file = Some(("letterhead.pdf".into(), std::sync::Arc::new(FIXTURE.to_vec())));
h.run_steps(2);
h.get_by_label("Page number");
h.get_by_label("Scale relative to target page");
h.get_by_label("OK").click();
h.run_steps(3);
assert_eq!(marks(&h), vec![MarkKind::Background]);
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Add background"));
}
/// Two 300 × 400 pages with content (a dark bar), the second turned a quarter (/Rotate 90), so it
/// is shown landscape, 400 × 300.
fn content_fixture() -> Vec<u8> {
let bar = "0 0 0 rg 40 300 220 60 re f";
format!(
"%PDF-1.7
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj
4 0 obj << /Type /Page /Parent 2 0 R /Rotate 90 /Contents 5 0 R >> endobj
5 0 obj << /Length {} >>
stream
{bar}
endstream
endobj
trailer << /Root 1 0 R >>
%%EOF",
bar.len()
)
.into_bytes()
}
/// The pictures of page `page` (its thumbnail) painted last frame: paint order, bounds, tint.
fn page_pictures(h: &Harness<'static, PdfCraftApp>, page: usize) -> Vec<(usize, egui::Rect, egui::Color32)> {
let Some(tex) = h.state().views[0].thumb(page).map(|t| t.id()) else { return Vec::new() };
h.output()
.shapes
.iter()
.enumerate()
.filter_map(|(k, s)| match &s.shape {
egui::Shape::Mesh(m) if m.texture_id == tex => Some((k, m.calc_bounds(), m.vertices.first()?.color)),
_ => None,
})
.collect()
}
/// The preview's picture of `page`, once its thumbnail has arrived (it renders off the UI thread).
fn wait_for_picture(h: &mut Harness<'static, PdfCraftApp>, page: usize) -> (usize, egui::Rect, egui::Color32) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
loop {
h.step();
if let [one] = page_pictures(h, page)[..] {
return one;
}
assert!(std::time::Instant::now() < deadline, "the preview never showed page {}'s picture", page + 1);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
/// A text painted on the page last frame (the dialog's text boxes show it too): its paint order
/// and its laid-out rectangle.
fn painted(h: &Harness<'static, PdfCraftApp>, text: &str, page: egui::Rect) -> (usize, egui::Rect) {
h.output()
.shapes
.iter()
.enumerate()
.find_map(|(k, s)| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == text => {
Some((k, t.galley.rect.translate(t.pos.to_vec2()))).filter(|(_, r)| page.contains(r.center()))
}
_ => None,
})
.unwrap_or_else(|| panic!("{text:?} is painted on the page"))
}
fn near(a: f32, b: f32, what: &str) {
assert!((a - b).abs() < 1.0, "{what}: {a} against {b}");
}
#[test]
fn the_preview_shows_the_page_the_range_selects_under_the_marks() {
use pdfcraft_ui_egui::marks::{PageRange, Subset};
let mut h = harness_with(content_fixture());
assert!(h.state_mut().execute("edit.header_footer"));
h.run_steps(2);
{
let d = &mut h.state_mut().marks_draft;
d.hf.text[1] = "TOP".into();
d.hf.text[5] = "END".into();
// Page 2 alone, not the current page: the preview follows the range (#2837).
d.range = PageRange { all: false, from: 2, to: 2, subset: Subset::All };
}
let (at, page, tint) = wait_for_picture(&mut h, 1);
h.get_by_label("Preview: page 2 of 2");
assert_eq!(tint, egui::Color32::WHITE);
assert!((page.width() / page.height() - 4.0 / 3.0).abs() < 0.01, "page 2 is shown turned, as displayed: {page:?}");
// The marks are drawn over the page, where the engine puts them on its displayed box
// (default margins: 36 pt top and bottom, 72 pt left and right).
let s = page.width() / 400.0;
let (top_at, top) = painted(&h, "TOP", page);
let (end_at, end) = painted(&h, "END", page);
assert!(at < top_at && at < end_at, "the page is under the marks");
near(top.center().x, page.center().x, "centre header, across");
near(top.center().y, page.top() + 36.0 * s, "centre header, down");
near(end.right(), page.right() - 72.0 * s, "right footer, across");
near(end.center().y, page.bottom() - 36.0 * s, "right footer, down");
h.get_by_label("Cancel").click();
h.run_steps(2);
// The watermark sits on the page's centre.
assert!(h.state_mut().execute("edit.watermark"));
h.run_steps(2);
{
let d = &mut h.state_mut().marks_draft;
d.wm.text = "DRAFT".into();
d.wm.rotation = 0.0;
}
let (at, page, _) = wait_for_picture(&mut h, 1);
let (mark_at, mark) = painted(&h, "DRAFT", page);
assert!(at < mark_at, "the page is under the watermark");
near(mark.center().x, page.center().x, "watermark, across");
near(mark.center().y, page.center().y, "watermark, down");
h.get_by_label("Cancel").click();
h.run_steps(2);
// A background colour shows where the page is white: it tints the page's picture.
assert!(h.state_mut().execute("edit.background"));
h.run_steps(2);
let (_, _, tint) = wait_for_picture(&mut h, 1);
assert_eq!(tint, egui::Color32::from_rgb(255, 255, 217), "the default pale yellow, opaque");
}
/// The preview's page stepper, beside its "page N of M" label: the dialog's own button, not the
/// page navigation's of the same name.
fn preview_step(h: &mut Harness<'static, PdfCraftApp>, label: &str, now: &str) {
let row = h.get_by_label(now).rect().center().y;
h.query_all_by_label(label).find(|n| (n.rect().center().y - row).abs() < 15.0).unwrap_or_else(|| panic!("{label} beside {now:?}")).click();
h.run_steps(2);
}
#[test]
fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() {
let mut h = harness();
h.state_mut().views[0].go_to_page(1);
h.run_steps(2);
assert!(h.state_mut().execute("edit.watermark"));
h.run_steps(2);
h.get_by_label("Preview: page 2 of 3");
preview_step(&mut h, "Next page", "Preview: page 2 of 3");
h.get_by_label("Preview: page 3 of 3");
preview_step(&mut h, "Previous page", "Preview: page 3 of 3");
preview_step(&mut h, "Previous page", "Preview: page 2 of 3");
h.get_by_label("Preview: page 1 of 3");
// Opened again, the preview is back at the current page.
h.get_by_label("Cancel").click();
h.run_steps(2);
assert!(h.state_mut().execute("edit.watermark"));
h.run_steps(2);
h.get_by_label("Preview: page 2 of 3");
}
/// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on
/// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on
/// the last page.
#[test]
fn page_range_from_one_to_one_selects_only_the_first_page() {
let mut h = harness();
h.state_mut().execute("edit.background");
h.run_steps(2);
assert_eq!(h.state().marks_draft.range.pages(3), [0, 1, 2]);
h.get_by_label("Pages from").click();
h.run_steps(2);
let range = h.state().marks_draft.range;
assert_eq!((range.from, range.to), (1, 3), "from the first page to the last");
h.state_mut().marks_draft.range.to = 1;
h.run_steps(3);
let range = h.state().marks_draft.range;
assert_eq!((range.from, range.to), (1, 1), "the range stays as typed");
h.get_by_label("Applies to 1 page");
h.get_by_label("OK").click();
h.run_steps(3);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
assert_eq!(doc.marks(), [MarkKind::Background]);
// Rendered: page 1 has the (default cream) background, page 2 is still blank.
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), pdfcraft_render::RenderConfig::default());
let corner = |r: &mut pdfcraft_render::PageRenderer, page: usize| {
let p = r.render(pdfcraft_render::RenderRequest { page, scale: 1.0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
p.rgba[..4].to_vec()
};
assert_eq!(corner(&mut r, 0), vec![255, 255, 217, 255], "page 1 has the background");
assert_eq!(corner(&mut r, 1), vec![255, 255, 255, 255], "page 2 does not");
}