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

695 lines
30 KiB
Rust

//! The command registry drives menus, shortcuts and the palette: every registered command must
//! be implemented, disabled commands must say why, and every surface must reach the same action.
use crate::support;
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::commands::COMMANDS;
use pdfcraft_ui_egui::{Dialog, PdfCraftApp};
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 24 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 offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", 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("doc.pdf", None, fixture(3)).unwrap();
app
});
h.run_steps(4);
h
}
/// Commands that open a native file picker can't run headless.
/// (Create ▸ Clipboard reads the system clipboard: tests use `create_from_clip`.)
const PICKERS: &[&str] = &[
"file.open",
"page.combine",
"page.insert",
"file.save_as",
"create.file",
"create.images",
"create.multiple",
"page.replace",
"create.clipboard",
"a11y.report",
"ocr.recognize_batch",
"form.merge_data",
"export.docx",
"export.pptx",
"export.html",
"export.rtf",
"measure.export",
];
#[test]
fn every_registered_command_is_implemented() {
for spec in COMMANDS {
// Page-text Copy needs a loaded view and egui context, covered by text_select.
if PICKERS.contains(&spec.id) || spec.id == "edit.copy" {
continue;
}
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
if spec.id.starts_with("form.") || spec.id == "comment.flatten" {
app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap();
} else if spec.id == "protect.unlock" {
// Only a permissions password: opens restricted, and can be unlocked.
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(3))).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: 0b0100,
encrypt_metadata: true,
seed: [5; 32],
})
.unwrap();
app.open_bytes("restricted.pdf", None, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap();
} else {
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
}
// Give undo/redo something to do.
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
if spec.id == "edit.redo" {
app.undo();
}
if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) {
use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark};
let mut hf = HeaderFooter::default();
hf.text[1] = "x".into();
app.apply_edit(Edit::AddHeaderFooter { pages: vec![0], settings: hf, replace: false });
app.apply_edit(Edit::AddWatermark {
pages: vec![0],
settings: Watermark { text: "x".into(), ..Watermark::default() },
replace: false,
file: None,
});
app.apply_edit(Edit::AddBackground {
pages: vec![0],
settings: Background { color: [1.0; 3], opacity: 1.0, ..Background::default() },
replace: false,
file: None,
});
}
if matches!(spec.id, "comment.flatten" | "comment.export" | "comment.summarize") {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let shape = Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] };
app.apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: String::new(),
}));
}
if spec.id.starts_with("redact.") {
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
let shape =
Shape::Redact { quads: vec![pdfcraft_engine::rect_quad([10.0, 10.0, 50.0, 50.0])], overlay: String::new(), look: Default::default() };
app.apply_edit(Edit::AddAnnotation(NewAnnotation {
page: 0,
style: Style::default_for(&shape),
shape,
contents: String::new(),
author: String::new(),
}));
}
if spec.id == "protect.remove" {
let p = pdfcraft_engine::Protection { open_password: Some("pw".into()), ..Default::default() };
app.apply_edit(pdfcraft_engine::Edit::Protect(p));
}
// The cover toggle needs two-page view first (it is disabled elsewhere).
if spec.id == "view.layout.cover" {
app.set_option("layout", "two-up").unwrap();
}
// History commands need a real jump (and Back first to make Forward available).
if matches!(spec.id, "view.previous_view" | "view.next_view") {
app.views[0].go_to_page(1);
if spec.id == "view.next_view" {
assert!(app.views[0].view_history(false));
}
}
let dir = std::env::temp_dir().join(format!("pdfcraft-cmd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
app.save_override = Some(dir.join("out.pdf").to_string_lossy().into_owned());
// A picker the list above misses (file.pin_folder, create.multiple_separate did) must not
// open a real file dialog on the desktop running the tests: answer it as a cancel.
app.pick_override = Some(Vec::new());
assert!(app.execute(spec.id), "{} is registered but not implemented (or wrongly disabled)", spec.id);
}
}
#[test]
fn disabled_commands_explain_themselves() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
assert!(!app.execute("file.save"));
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Open a document first"));
app.open_bytes("doc.pdf", None, fixture(2)).unwrap();
assert!(!app.execute("edit.undo"));
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Nothing to undo"));
// View state counts too: the cover page exists only in two-page view.
assert!(!app.execute("view.layout.cover"));
assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("Switch to two-page view first to show the cover page"));
assert!(!app.execute("no.such.command"));
}
#[test]
fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() {
let dir = std::env::temp_dir().join(format!("pdfcraft-tool-open-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("picked.pdf");
std::fs::write(&path, fixture(2)).unwrap();
let pick = path.to_string_lossy().into_owned();
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.pick_override = Some(vec![pick]);
app
});
h.run_steps(2);
h.state_mut().run_command("export.text");
assert!(h.state().toast.as_ref().is_none_or(|t| t.0 != "Open a document first"));
support::wait_until(&mut h, "picked document and chosen tool", |app| app.active.is_some() && matches!(app.dialog, Some(Dialog::Export(_))));
assert!(h.state().active.is_some(), "the picked file is open");
assert!(matches!(h.state().dialog, Some(Dialog::Export(_))), "and the tool runs on it: {:?}", h.state().dialog);
let (_, id) = h.state().active_ids().unwrap();
let doc = h.state().session.get(id).unwrap();
assert_eq!(doc.name, "picked.pdf");
assert_eq!(doc.bytes.as_slice(), fixture(2).as_slice());
// Cancelled: nothing opens and nothing runs.
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.pick_override = Some(Vec::new());
app
});
h.run_steps(2);
h.state_mut().run_command("export.text");
h.run_steps(3);
assert!(h.state().active.is_none() && h.state().dialog.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_tool_chosen_with_an_already_open_file_focuses_its_tab_and_still_runs() {
let dir = std::env::temp_dir().join(format!("pdfcraft-tool-focus-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("picked.pdf");
std::fs::write(&path, fixture(2)).unwrap();
let pick = path.to_string_lossy().into_owned();
let picked = pick.clone();
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();
// The file is already open, but the Home tab is showing (no active document).
app.open_path(&picked);
app.active = None;
app.pick_override = Some(vec![pick]);
app
});
h.run_steps(2);
h.state_mut().run_command("export.text");
h.run_steps(3);
let app = h.state();
assert_eq!(app.views.len(), 1, "the existing tab is reused, not duplicated");
assert_eq!(app.active, Some(0), "the pick focused the tab it already opened");
assert!(matches!(app.dialog, Some(Dialog::Export(_))), "the tool still runs on it: {:?}", app.dialog);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn shortcuts_run_registered_commands() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::D);
h.run_steps(2);
assert!(matches!(h.state().dialog, Some(Dialog::Properties(_))));
h.state_mut().dialog = None;
h.key_press_modifiers(Modifiers::COMMAND, Key::K);
h.run_steps(2);
assert!(h.state().palette_open);
h.key_press(Key::Escape);
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::CTRL, Key::H);
h.run_steps(2);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
}
#[test]
fn the_palette_lists_commands_with_shortcuts_and_runs_them() {
let mut h = harness();
h.state_mut().set_option("palette", "rotate pages clockwise").unwrap();
h.run_steps(3);
h.get_by_label("Rotate pages clockwise");
h.key_press(Key::Enter);
h.run_steps(3);
let app = h.state();
assert!(!app.palette_open, "Enter runs the top hit and closes the palette");
let doc = app.session.get(app.views[0].id).unwrap();
assert_eq!(doc.info.pages[0].rotation, 90);
assert_eq!(doc.can_undo(), Some("Rotate page"));
}
#[test]
fn palette_arrow_keys_select_results_and_enter_runs_the_selection() {
let mut h = harness();
h.state_mut().set_option("palette", "rotate pages").unwrap();
h.run_steps(2);
h.get_by_label("Rotate pages clockwise");
h.get_by_label("Rotate pages counterclockwise");
h.key_press(Key::ArrowDown);
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(2);
let app = h.state();
assert!(!app.palette_open);
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270);
}
/// Check that choosing Edit ▸ Pages ▸ Delete pages in the menu bar reduces the page count once.
#[test]
fn the_pages_menu_comes_from_the_registry() {
let mut h = harness();
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Pages").hover();
h.run_steps(3);
support::menu_row(&h, "Delete pages").click();
h.run_steps(3);
let app = h.state();
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages.len(), 2);
}
/// Check that Open Recent lists a recorded file and opens it in a new active tab without replacing the
/// original.
#[test]
fn the_open_recent_menu_lists_files_and_opens_one() {
let dir = std::env::temp_dir().join(format!("pdfcraft-recent-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("recent.pdf");
std::fs::write(&path, fixture(2)).unwrap();
let path = path.to_string_lossy().into_owned();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe({
let path = path.clone();
move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
app.recent.push(pdfcraft_ui_egui::RecentFile { name: "recent.pdf".into(), path, pages: 2, size: 0 });
app
}
});
h.run_steps(4);
support::open_menu(&mut h, "File");
support::menu_row(&h, "Open Recent").hover();
h.run_steps(3);
let original = h.state().views[0].id;
support::menu_row(&h, "recent.pdf").click();
support::wait_until(&mut h, "recent document", |app| app.views.len() == 2 && app.password_prompt.is_none());
let app = h.state();
let active = app.active.unwrap();
assert_eq!(app.session.get(app.views[active].id).and_then(|d| d.path.as_deref()), Some(path.as_str()), "the active tab is the recent file");
assert_eq!(app.session.get(app.views[active].id).unwrap().bytes.as_slice(), fixture(2).as_slice());
assert!(app.views.iter().any(|view| view.id == original));
assert_eq!(app.session.get(original).unwrap().bytes.as_slice(), fixture(3).as_slice());
}
/// Check that Open Recent is disabled while no file has been opened.
#[test]
fn the_open_recent_menu_is_disabled_while_the_list_is_empty() {
let mut h = harness();
support::open_menu(&mut h, "File");
assert!(
h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(),
"Open Recent is disabled while no file has been opened"
);
}
fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile {
pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 }
}
/// Check that Clear Recent Files empties the list and disables Open Recent.
#[test]
fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() {
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("doc.pdf", None, fixture(3)).unwrap();
app.recent.extend([recent_file("first.pdf"), recent_file("second.pdf")]);
app
});
h.run_steps(4);
support::open_menu(&mut h, "File");
support::menu_row(&h, "Open Recent").hover();
h.run_steps(3);
support::menu_row(&h, "Clear Recent Files").click();
h.run_steps(3);
assert!(h.state().recent.is_empty(), "the list is empty");
support::open_menu(&mut h, "File");
assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled");
}
#[test]
fn clear_recent_files_runs_from_the_palette_and_is_saved() {
let mut app = PdfCraftApp::new();
app.recent.extend([recent_file("first.pdf"), recent_file("second.pdf")]);
assert!(app.execute("file.clear_recent"));
assert!(app.recent.is_empty());
let mut restarted = PdfCraftApp::new();
restarted.recent.push(recent_file("stale.pdf"));
restarted.restore(&app.persist());
assert!(restarted.recent.is_empty(), "the empty list is what's saved");
app.execute("file.clear_recent");
assert!(app.toast.as_ref().is_some_and(|(m, _)| m.contains("No recent files")), "nothing to clear is said, not silent");
}
#[test]
fn the_shortcuts_dialog_lists_the_real_bindings() {
let mut h = harness();
h.ctx.set_os(OperatingSystem::Nix);
h.state_mut().execute("help.shortcuts");
h.run_steps(3);
h.get_by_label("Ctrl+Shift+S");
h.get_by_label("Save as");
// The keys the document view handles itself are written for the platform too, and Fit
// visible (a registered command) is listed once.
h.get_by_label("Ctrl+1");
h.get_by_label("Ctrl+3");
h.get_by_label("Ctrl+G / Ctrl+Shift+G");
h.get_by_label("← / →, Ctrl+← / Ctrl+→");
h.get_by_label("Zoom in / out (also pinch or Ctrl-scroll)");
assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS");
h.ctx.set_os(OperatingSystem::Mac);
h.run_steps(2);
h.get_by_label("⇧⌘S");
h.get_by_label("⌘1");
h.get_by_label("Zoom in / out (also pinch or ⌘-scroll)");
}
#[test]
fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings() {
let mut h = harness();
h.ctx.set_os(OperatingSystem::Windows);
h.state_mut().set_option("language", "de").unwrap();
h.state_mut().execute("help.shortcuts");
h.run_steps(3);
h.get_by_label("Strg+Umschalt+S");
h.state_mut().set_option("language", "en").unwrap();
h.run_steps(2);
h.get_by_label("Ctrl+Shift+S");
h.state_mut().set_option("language", "de").unwrap();
h.ctx.set_os(OperatingSystem::Mac);
h.run_steps(2);
h.get_by_label("⇧⌘S");
h.key_press(Key::Escape);
h.run_steps(2);
h.ctx.set_os(OperatingSystem::Windows);
h.key_press_modifiers(Modifiers::CTRL | Modifiers::COMMAND, Key::K);
h.run_steps(3);
assert!(h.state().palette_open, "the localized Strg+K display still binds Ctrl+K");
// Searching by the stable command id still works in a translated interface.
h.state_mut().set_option("palette", "view.read_mode").unwrap();
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read);
assert!(!h.state().palette_open);
let doc = h.state().session.get(h.state().views[0].id).unwrap();
assert!(!doc.dirty);
}
/// View ▸ Zoom open in the menu bar, with egui told the app runs on `os`.
fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> {
let mut h = harness();
h.ctx.set_os(os);
h.run_steps(2);
support::open_menu(&mut h, "View");
support::menu_row(&h, "Zoom").hover();
h.run_steps(3);
h
}
#[test]
fn view_menu_and_tooltips_write_shortcuts_for_the_platform() {
// On Linux, View ▸ Zoom and Page navigation showed ⌘1, ⌘[ … (hard-coded) while the
// registered items under them showed Ctrl+K; tooltips showed ⌘ too.
// The menu bar writes its shortcuts for the build's platform (craft-menu tests that).
let h = view_menu(OperatingSystem::Nix);
h.get_by_label("Zoom in (Ctrl++)");
h.get_by_label("Print (Ctrl+P)");
assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS");
assert_eq!(h.query_all_by_label_contains("Fit visible").count(), 1, "View lists Fit visible once");
let h = view_menu(OperatingSystem::Mac);
h.get_by_label("Rotate view clockwise (⇧⌘+)");
assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS");
}
#[test]
fn the_top_bar_save_button_names_its_shortcut_not_a_milestone() {
// #962: on Windows the Save button's tooltip and accessibility name read "Save (M4)",
// an internal milestone tag, while Print and Document properties named their shortcuts.
let mut h = harness();
h.ctx.set_os(OperatingSystem::Windows);
h.run_steps(2);
h.get_by_label("Save (Ctrl+S)");
h.get_by_label("Print (Ctrl+P)");
assert_eq!(h.query_all_by_label_contains("(M4)").count(), 0, "no milestone tag in the top bar");
assert_eq!(h.query_all_by_label_contains("⌘").count(), 0, "no ⌘ outside macOS");
let mut h = harness();
h.ctx.set_os(OperatingSystem::Mac);
h.run_steps(2);
h.get_by_label("Save (⌘S)");
}
#[test]
fn full_screen_toggles_by_shortcut_and_command_and_escape_leaves() {
let mut h = harness();
h.key_press_modifiers(Modifiers::COMMAND, Key::L);
h.run_steps(2);
assert!(h.state().full_screen, "⌘L / Ctrl+L enters full screen");
h.key_press(Key::Escape);
h.run_steps(2);
assert!(!h.state().full_screen, "Escape leaves full screen");
h.state_mut().execute("view.full_screen");
h.run_steps(2);
assert!(h.state().full_screen);
h.state_mut().execute("view.full_screen");
h.run_steps(2);
assert!(!h.state().full_screen, "the command toggles back");
}
#[test]
fn the_about_dialog_shows_the_running_version() {
let mut h = harness();
h.state_mut().execute("help.about");
h.run_steps(3);
h.get_by_label_contains(&format!("Version {}", env!("CARGO_PKG_VERSION")));
}
/// Check that the quit shortcut needs exact platform modifiers and that unsaved work opens the save prompt
/// instead of closing.
#[test]
fn quit_shortcut_requires_exact_modifiers_and_preserves_unsaved_work() {
for (os, modifiers) in [
(OperatingSystem::Windows, Modifiers { ctrl: true, command: true, ..Default::default() }),
(OperatingSystem::Mac, Modifiers { mac_cmd: true, command: true, ..Default::default() }),
] {
for (extra, dirty, should_close) in
[(Modifiers::ALT, false, false), (Modifiers::SHIFT, false, false), (Modifiers::NONE, false, true), (Modifiers::NONE, true, false)]
{
let mut h = harness();
h.ctx.set_os(os);
if dirty {
assert!(h.state_mut().execute("page.insert_blank"));
h.run_steps(2);
}
h.event(egui::Event::Key { key: Key::Q, physical_key: None, pressed: true, repeat: false, modifiers: modifiers | extra });
h.step();
let closes = h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close));
assert_eq!(closes, should_close, "{os:?}, extra={extra:?}, dirty={dirty}");
if dirty {
// The save prompt is a modal laid out after the key pass; let that pass settle
// before looking it up.
h.run_steps(2);
h.get_by_label("Cancel").click();
h.run_steps(2);
assert_eq!(h.state().views.len(), 1);
assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty);
assert!(!h.output().viewport_output.values().any(|v| v.commands.contains(&egui::ViewportCommand::Close)));
}
}
}
}
#[test]
fn command_w_closes_empty_macos_window_but_not_an_empty_windows_window() {
for (os, should_close) in [(OperatingSystem::Mac, true), (OperatingSystem::Windows, false)] {
let mut h = Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_eframe(|_cc| PdfCraftApp::new());
h.ctx.set_os(os);
h.run_steps(4);
assert!(h.state().views.is_empty());
h.event(egui::Event::Key { key: Key::W, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
h.step();
let closes = h.output().viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Close)));
assert_eq!(closes, should_close, "{os:?}: only an empty macOS window should close");
}
}
#[test]
fn command_w_still_closes_the_document_tab_on_macos() {
let mut h = harness();
h.ctx.set_os(OperatingSystem::Mac);
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(2);
assert!(h.state().views.is_empty(), "⌘W closes the clean document tab");
}
fn palette_ime_output_requests_interrupt(h: &Harness<'_, PdfCraftApp>, phase: &str) -> bool {
assert!(h.state().palette_open, "palette closed during {phase}");
let ime = h.output().platform_output.ime.as_ref().expect("focused palette advertises IME");
eprintln!("{phase}: query={:?}; should_interrupt_composition={}", h.state().palette_query, ime.should_interrupt_composition);
ime.should_interrupt_composition
}
fn palette_ime_output_is_uninterrupted(h: &Harness<'_, PdfCraftApp>, phase: &str) {
assert!(!palette_ime_output_requests_interrupt(h, phase), "palette interrupted composition during {phase}");
}
#[test]
fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() {
let mut h = harness();
for opening in 0..2 {
h.state_mut().run_command("view.palette");
h.run_steps(3);
for frame in 0..3 {
h.step();
palette_ime_output_is_uninterrupted(&h, &format!("opening {opening}, idle {frame}"));
}
h.key_press(Key::Escape);
h.run_steps(2);
assert!(!h.state().palette_open);
}
}
#[test]
fn palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted() {
if crate::support::skip_without_glyphs("palette_ime_chinese_preedit_commit_and_cancel_remain_uninterrupted", "en", "你") {
return;
}
let mut h = harness();
let mut interruptions = Vec::new();
h.state_mut().run_command("view.palette");
h.run_steps(3);
for preedit in ["ni", "nih"] {
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: preedit.into(), active_range_chars: Some(0..preedit.chars().count()) }));
h.step();
interruptions.push(palette_ime_output_requests_interrupt(&h, preedit));
h.step();
interruptions.push(palette_ime_output_requests_interrupt(&h, "preedit idle"));
}
h.event(egui::Event::Ime(egui::ImeEvent::Commit("你".into())));
h.step();
assert_eq!(h.state().palette_query, "你");
interruptions.push(palette_ime_output_requests_interrupt(&h, "Chinese commit"));
h.run_steps(2);
assert_eq!(h.state().palette_query, "你");
interruptions.push(palette_ime_output_requests_interrupt(&h, "committed idle"));
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "hao".into(), active_range_chars: Some(0..3) }));
h.step();
interruptions.push(palette_ime_output_requests_interrupt(&h, "second preedit"));
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None }));
h.step();
assert_eq!(h.state().palette_query, "你");
interruptions.push(palette_ime_output_requests_interrupt(&h, "cancel preedit"));
assert!(interruptions.iter().all(|interrupt| !interrupt), "composition interrupted: {interruptions:?}");
}
#[test]
fn palette_ime_outside_click_closes_without_running_a_command() {
let mut h = harness();
h.state_mut().set_option("palette", "rotate pages clockwise").unwrap();
h.run_steps(3);
h.event(egui::Event::PointerButton {
pos: egui::pos2(20.0, 850.0),
button: egui::PointerButton::Primary,
pressed: true,
modifiers: Modifiers::NONE,
});
h.step();
assert!(!h.state().palette_open);
assert!(h.state().palette_query.is_empty());
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().info.pages[0].rotation, 0);
}
/// Ctrl+Tab and Ctrl+Shift+Tab step through the tabs in the strip's order, wrap around at either
/// end and include the Combine files tab after the documents (#1542). A Windows or Linux Ctrl
/// press is both `ctrl` and `command`.
#[test]
fn ctrl_tab_steps_through_the_tabs_in_order_and_wraps_around() {
let mut h = harness();
h.state_mut().open_bytes("second.pdf", None, fixture(1)).unwrap();
h.state_mut().open_bytes("third.pdf", None, fixture(1)).unwrap();
h.run_steps(3);
let ctrl = Modifiers::CTRL | Modifiers::COMMAND;
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers: Modifiers| {
h.key_press_modifiers(modifiers, Key::Tab);
h.run_steps(2);
h.state().active
};
assert_eq!(h.state().active, Some(2));
assert_eq!(press(&mut h, ctrl), Some(0), "the last tab wraps to the first");
assert_eq!(press(&mut h, ctrl), Some(1));
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(0));
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), Some(2), "the first tab wraps to the last");
// The order is the one on screen, after the tabs are moved.
let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect();
h.state_mut().move_tab(2, 0);
h.run_steps(2);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[2]));
press(&mut h, ctrl);
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(ids[0]));
// Combine files comes after the documents.
h.state_mut().execute("page.combine");
h.run_steps(2);
assert!(h.state().combine_showing());
assert_eq!(press(&mut h, ctrl), Some(0), "Combine files wraps to the first document");
assert_eq!(press(&mut h, ctrl | Modifiers::SHIFT), None);
assert!(h.state().combine_showing(), "back from the first document is Combine files");
// Plain Tab still belongs to keyboard focus, not to the tabs.
press(&mut h, Modifiers::NONE);
assert!(h.state().combine_showing());
for doc in h.state().session.docs() {
assert!(!doc.dirty, "switching tabs changes no document");
}
}