M2: sync validated CJK viewer PR with latest main

This commit is contained in:
songzhihui committed 2026-10-26 13:55:39 +08:00
commit d865886d39
26 files changed
+1079 -329

No files matched your search

Generated
+36 -13
View File
@@ -229,7 +229,7 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -1171,6 +1171,29 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"egui",
"serde",
"serde_json",
]
[[package]]
name = "craft-menu"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"craft-keymap",
"craft-quit",
"egui",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-print"
version = "0.1.0"
@@ -1187,11 +1210,10 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"dispatch2",
"egui",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
@@ -1199,7 +1221,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"egui",
"log",
@@ -1948,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -3941,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4035,7 +4057,6 @@ name = "pdfcraft"
version = "0.14.0"
dependencies = [
"chrono",
"craft-quit",
"craft-update",
"eframe",
"egui",
@@ -4469,6 +4490,8 @@ dependencies = [
"block2 0.6.2",
"bytemuck",
"chrono",
"craft-keymap",
"craft-menu",
"craft-quit",
"craft-ui",
"craft-update",
@@ -5071,7 +5094,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5592,7 +5615,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5662,7 +5685,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6546,10 +6569,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -7661,7 +7684,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+5 -2
View File
@@ -13,9 +13,12 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# Shared frontend components; engines remain independent of egui.
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68", features = ["serde"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }
# Shared system spooler; document composition remains in pdfcraft-print.
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
# Whether a path stored in a document may be opened (GoToR link targets).
-3
View File
@@ -29,9 +29,6 @@ chrono = { workspace = true }
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
# window to close instead of winit's, which ends the process without asking.
craft-quit = { workspace = true, features = ["menu"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
+5 -4
View File
@@ -429,10 +429,11 @@ fn app_creator<'a>(
#[cfg(target_os = "macos")]
{
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
// The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
// winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
// button.
if !app.install_native_menu(&cc.egui_ctx) {
log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
}
}
if let Some(server) = instance {
+6 -3
View File
@@ -11,6 +11,12 @@ workspace = true
[dependencies]
craft-ui = { workspace = true }
# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs).
craft-menu = { workspace = true }
craft-keymap = { workspace = true }
# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window
# close also quits through `terminate:` (the Touch Bar crash, #1136).
craft-quit = { workspace = true, features = ["egui"] }
pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
@@ -55,9 +61,6 @@ pollster = "1.0"
fontconfig-parser = "0.5.8"
[target.'cfg(target_os = "macos")'.dependencies]
# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op.
# The egui feature binds `on_close_request` to the app's egui Context, so it stays macOS-only.
craft-quit = { workspace = true, features = ["egui"] }
# Safe AppKit calls for drag coordinates and repairing an existing per-file PDFCraft binding.
# The Objective-C wrappers and block support are already in the tree through arboard and rfd.
objc2 = "0.6"
+8 -121
View File
@@ -311,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
/// Both theme entry points use the same choices and command path.
fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
for (preference, command, label) in [
(ThemePreference::System, "view.theme.system", "Use system setting"),
(ThemePreference::Light, "view.theme.light", "Light gray"),
(ThemePreference::Dark, "view.theme.dark", "Dark gray"),
] {
if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() {
app.execute(command);
ui.close();
}
}
ui.separator();
if ui.button(tl!("Appearance settings…")).clicked() {
app.dialog = Some(Dialog::Preferences);
ui.close();
}
}
/// The right-click menu on the title bar's theme button.
fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
if ui.button(tl!("Appearance settings…")).clicked() {
@@ -497,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
main_menu(app, ui);
ui.add_space(6.0);
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
ui.add_space(10.0);
// The menus, unless the native macOS menu bar has them.
if app.native_menu.is_none() {
app.menu_bar_ui(ui);
ui.add_space(6.0);
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
ui.add_space(10.0);
}
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
@@ -540,103 +524,6 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
});
}
/// Keep menus usable when their contents are taller than the window.
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
}
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
egui::Popup::menu(&resp).show(|ui| {
scroll_menu(ui, |ui| {
ui.set_min_width(230.0);
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
ui.menu_button(tl!("View"), |ui| {
scroll_menu(ui, |ui| {
if let Some(i) = app.active {
let v = &mut app.views[i];
let ctx = ui.ctx().clone();
let key = |s| crate::commands::shortcut_label(&ctx, s);
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
v.set_zoom(1.0);
}
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
v.fit = Fit::Page;
}
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
v.fit = Fit::Width;
}
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
ui.close();
app.execute("view.fit_visible");
return;
}
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
v.zoom_step(true);
}
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
v.zoom_step(false);
}
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
v.rotate_view(true);
}
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
v.rotate_view(false);
}
ui.separator();
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
v.view_history(false);
}
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
v.view_history(true);
}
ui.separator();
if let Some(id) = page_display_menu(ui, &app.views[i]) {
app.execute(id);
}
ui.separator();
}
crate::commands::registry_menu(app, ui, "View");
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
ui.menu_button(tl!("Side panels"), |ui| {
scroll_menu(ui, |ui| {
for (p, label) in [
(RightPanel::Comments, tl!("Comments")),
(RightPanel::Bookmarks, tl!("Bookmarks")),
(RightPanel::Pages, tl!("Pages")),
(RightPanel::Fields, tl!("Fields")),
(RightPanel::Layers, tl!("Layers")),
(RightPanel::Attachments, tl!("Attachments")),
(RightPanel::Signatures, tl!("Signatures")),
(RightPanel::Accessibility, tl!("Accessibility Checker")),
(RightPanel::Tags, tl!("Tags")),
(RightPanel::Search, tl!("Search")),
(RightPanel::Compare, tl!("Compare")),
] {
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
app.choose_right_panel(Some(p));
}
}
})
});
})
});
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
})
});
}
/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the
/// active view.
pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
@@ -822,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String {
if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") }
}
/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page.
/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display).
/// Returns the command a click asks for, for the caller to run.
fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> {
let t = Tokens::get(ui.ctx());
+20 -71
View File
@@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut};
use crate::{
Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget,
theme::{ThemeKind, ThemePreference},
widgets,
};
/// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and
@@ -724,11 +723,6 @@ impl PdfCraftApp {
// Buttons also own keyboard focus for Tab/Enter activation, but they must not
// suppress document shortcuts. Text editors retain their own undo/redo handling.
let typing = ctx.text_edit_focused();
// A form field's editor is open on the page: its text isn't in the document until
// committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that,
// while the field has yet to see the Escape and discard its draft.)
let active = self.active_ids();
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect();
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
for spec in specs {
@@ -752,23 +746,29 @@ impl PdfCraftApp {
m |= Modifiers::CTRL;
}
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) {
if form_typing {
// A form field has the keyboard: let it take this frame's typing (and
// Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for
// this document only.
self.deferred_commands.push((spec.id, active.map(|(_, id)| id)));
ctx.request_repaint();
} else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
// macOS ⌘W closes the last window when no document tabs remain (#869).
// Route through the normal viewport-close path (and its quit guard),
// not the disabled document-only `file.close` command.
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
self.execute(spec.id);
}
self.dispatch_command(ctx, spec.id);
}
}
}
/// Run a registry command from its shortcut or a menu, which share this path.
pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) {
let active = self.active_ids();
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
if form_typing {
// A form field has the keyboard: let it take this frame's typing (and Escape) first,
// so ⌘S saves what's on screen (#166). Runs next frame, for this document only.
self.deferred_commands.push((id, active.map(|(_, id)| id)));
ctx.request_repaint();
} else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
// macOS ⌘W closes the last window when no document tabs remain (#869). Route through
// the normal viewport-close path (and its quit guard), not the disabled document-only
// `file.close` command.
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else {
self.execute(id);
}
}
}
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
@@ -789,57 +789,6 @@ fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usiz
})
}
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
for spec in commands::menu(menu) {
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
let label = crate::i18n::menu_label(spec.id, &label);
// Open Recent is a submenu of the live recent list, not one action: disabled while the
// list is empty, otherwise each entry opens its file (or focuses the tab showing it), and
// Clear Recent Files at the foot empties the list (#430).
if spec.id == "file.open_recent" {
if app.recent.is_empty() {
ui.add_enabled(false, egui::Button::new(label));
continue;
}
let mut open: Option<String> = None;
let mut clear = false;
ui.menu_button(label, |ui| {
crate::chrome::scroll_menu(ui, |ui| {
for r in &app.recent {
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
open = Some(r.path.clone());
ui.close();
}
}
ui.separator();
if ui.button(tl!("Clear Recent Files")).clicked() {
clear = true;
ui.close();
}
})
});
if let Some(p) = open {
app.open_recent(&p);
ui.close();
}
if clear {
app.execute("file.clear_recent");
ui.close();
}
continue;
}
let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default();
let enabled = app.command_enabled(spec);
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut));
if resp.clicked() {
app.execute(spec.id);
ui.close();
}
}
let _ = widgets::menu_item;
}
#[cfg(test)]
mod tests {
use super::*;
+24 -1
View File
@@ -63,6 +63,7 @@ mod js_ui;
mod macos_association;
mod marks_ui;
mod measure_ui;
mod menus;
mod ocr_ui;
mod optimize_ui;
mod search_ui;
@@ -927,6 +928,10 @@ pub struct PdfCraftApp {
/// Shortcuts pressed while a text field had the keyboard, run on the next frame (see
/// `registry_shortcuts`).
deferred_commands: Vec<(&'static str, Option<DocId>)>,
/// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise.
pub(crate) native_menu: Option<craft_menu::NativeMenu>,
/// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`.
pub(crate) menu_input: Vec<egui::Event>,
/// The dialog seen at the last check, and a counter bumped whenever it changes (see
/// [`Self::dialog_epoch`]).
dialog_seen: Option<Dialog>,
@@ -1261,6 +1266,8 @@ impl PdfCraftApp {
last_autosave: None,
allow_quit: false,
deferred_commands: Vec::new(),
native_menu: None,
menu_input: Vec::new(),
dialog_seen: None,
dialog_epoch: 0,
ctx: None,
@@ -3291,7 +3298,12 @@ impl PdfCraftApp {
{
return;
}
self.registry_shortcuts(ctx);
// The in-window menu bar holds the keys while one of its menus is open or it has the
// keyboard (Alt, F10); a command shortcut leaves it and runs.
craft_menu::bar::leave_for_shortcut(ctx);
if !craft_menu::bar::is_open(ctx) && !craft_menu::bar::is_focused(ctx) {
self.registry_shortcuts(ctx);
}
// (Esc closes a Combine file shown large first.)
if self.full_screen && self.combine_tab.preview.is_none() && ctx.input(|i| i.key_pressed(Key::Escape)) {
self.set_full_screen(ctx, false);
@@ -3499,6 +3511,10 @@ impl PdfCraftApp {
}
// Before egui reads this frame's wheel input.
self.scroll_speed.apply(ctx, raw);
// The native menu's key equivalents.
if let Some(native) = self.native_menu.as_mut() {
native.raw_input(raw);
}
}
}
@@ -3670,6 +3686,12 @@ impl eframe::App for PdfCraftApp {
self.poll_print();
// Shortcuts deferred last frame: the text field has taken that frame's typing since.
let deferred = std::mem::take(&mut self.deferred_commands);
self.native_menu_clicks(ctx);
// The keys a menu click stands for (⌘C, Delete…), before anything reads this frame's keys.
if !self.menu_input.is_empty() {
let events = std::mem::take(&mut self.menu_input);
ctx.input_mut(|i| i.events.extend(events));
}
self.shortcuts(ctx);
// Scrolling and group-drag previews are transient: never resume after changing tabs,
// opening a modal/palette, or returning to a window that lost focus.
@@ -3700,6 +3722,7 @@ impl eframe::App for PdfCraftApp {
self.process_picked();
// Last, after everything that can open or answer the "save changes?" question.
self.attend_save_prompt(ctx);
self.sync_native_menu(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+791
View File
@@ -0,0 +1,791 @@
//! The menus: one definition ([`PdfCraftApp::menu_bar`]) for the native macOS menu bar and the
//! in-window bar on Windows, Linux and the web, laid out for each platform by craft-menu.
//!
//! Items run registry commands (`pdfcraft_engine::commands`) by id, with the registry's label and
//! shortcut, so a menu click and its shortcut run the same command. A few items have menu-only
//! ids: the zoom keys the document view handles itself, the side panel choices, the clipboard
//! keys, the recent files and the open documents. [`PdfCraftApp::run_menu_command`] runs every
//! one of them, once per click, whichever bar it came from.
use craft_keymap::{Key, Mods};
use craft_menu::{
Builtin, Chord, CommandRef, Document, Gate, Item, ItemRole, ItemState, Labeled, LayoutOptions, Menu, MenuBar, MenuRole, Node, Target,
};
use pdfcraft_engine::catalog::{Availability, TOOL_GROUPS};
use pdfcraft_engine::commands::{self, Shortcut};
use crate::canvas::{Fit, PageLayout};
use crate::theme::ThemePreference;
use crate::{PdfCraftApp, RightPanel};
/// The app's name in the menus the layouts write (About, Hide, Quit).
pub(crate) const APP_NAME: &str = "PdfCraft";
/// The top bar's width after the menus: the mode tabs, the search box and the buttons.
const ROW_AFTER_MENUS: f32 = 720.0;
/// The narrowest the menu bar gets: one "Menu" title.
const MIN_MENUS_WIDTH: f32 = 80.0;
/// Menu-only commands. The registry's commands keep their own ids.
pub(crate) const CUT: &str = "edit.cut";
pub(crate) const PASTE: &str = "edit.paste";
pub(crate) const DELETE: &str = "edit.delete";
pub(crate) const SELECT_ALL: &str = "edit.select_all";
pub(crate) const NO_RECENT: &str = "file.no_recent";
/// An open document in the Window menu, `{"index": n}` in the tab strip.
pub(crate) const DOCUMENT: &str = "window.document";
/// A side panel, `{"panel": name}` ([`PANELS`]).
pub(crate) const PANEL: &str = "view.panel";
const ACTUAL_SIZE: &str = "view.zoom.actual_size";
const PAGE_LEVEL: &str = "view.zoom.page_level";
const FIT_WIDTH: &str = "view.zoom.fit_width";
const FIT_HEIGHT: &str = "view.zoom.fit_height";
const ZOOM_IN: &str = "view.zoom.in";
const ZOOM_OUT: &str = "view.zoom.out";
const ROTATE_CW: &str = "view.rotate.cw";
const ROTATE_CCW: &str = "view.rotate.ccw";
/// The menu-only items: id, English label and the shortcut shown (the keys stay with the view).
const EXTRA: &[(&str, &str, Option<Shortcut>)] = &[
(CUT, "Cut", Some(Shortcut::cmd("X"))),
(PASTE, "Paste", Some(Shortcut::cmd("V"))),
(DELETE, "Delete", Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "Delete" })),
(SELECT_ALL, "Select all", Some(crate::commands::SELECT_ALL)),
(NO_RECENT, "No recent files", None),
(ACTUAL_SIZE, "Actual size", Some(crate::commands::ACTUAL_SIZE)),
(PAGE_LEVEL, "Zoom to page level", Some(crate::commands::PAGE_LEVEL)),
(FIT_WIDTH, "Fit to width", Some(crate::commands::FIT_WIDTH)),
(FIT_HEIGHT, "Fit to height", None),
(ZOOM_IN, "Zoom in", Some(crate::commands::ZOOM_IN)),
(ZOOM_OUT, "Zoom out", Some(crate::commands::ZOOM_OUT)),
(ROTATE_CW, "Rotate view clockwise", Some(crate::commands::ROTATE_CW)),
(ROTATE_CCW, "Rotate view counterclockwise", Some(crate::commands::ROTATE_CCW)),
];
/// View ▸ Side panels: the panel, its id in [`PANEL`] commands and its English label.
const PANELS: &[(RightPanel, &str, &str)] = &[
(RightPanel::Comments, "comments", "Comments"),
(RightPanel::Bookmarks, "bookmarks", "Bookmarks"),
(RightPanel::Pages, "pages", "Pages"),
(RightPanel::Fields, "fields", "Fields"),
(RightPanel::Layers, "layers", "Layers"),
(RightPanel::Attachments, "attachments", "Attachments"),
(RightPanel::Signatures, "signatures", "Signatures"),
(RightPanel::Accessibility, "accessibility", "Accessibility Checker"),
(RightPanel::Tags, "tags", "Tags"),
(RightPanel::Search, "search", "Search"),
(RightPanel::Compare, "compare", "Compare"),
];
/// The English titles of the menus and submenus, for the bar's mnemonics in languages whose
/// labels have no ASCII letter.
const TITLES: &[&str] = &[
"File",
"Edit",
"View",
"Pages",
"Sign",
"Window",
"Help",
"Open Recent",
"Create",
"Export",
"Protect",
"Zoom",
"Rotate view",
"Page navigation",
"Page display",
"Side panels",
"Display theme",
"Tools",
"Fill & Sign",
];
/// Separator.
const SEP: &str = "---";
const FILE: &[&str] = &[
"create.blank",
"file.open",
"file.open_recent",
"file.pin_folder",
SEP,
"page.combine",
SEP,
"file.save",
"file.save_as",
"file.revert",
SEP,
"file.close",
"file.close_all",
SEP,
"optimize.reduce",
"optimize.advanced",
SEP,
"file.properties",
"file.word_count",
SEP,
"print.dialog",
];
const CREATE: &[&str] = &["create.file", "create.multiple", "create.multiple_separate", "create.images", "create.clipboard"];
const EXPORT: &[&str] = &["export.docx", "export.pptx", "export.rtf", "export.html", "export.text", SEP, "export.image", "export.all_images"];
const PROTECT: &[&str] = &["protect.password", "protect.remove", "protect.unlock", SEP, "protect.properties"];
const EDIT: &[&str] = &[
"edit.undo",
"edit.redo",
SEP,
CUT,
"edit.copy",
PASTE,
DELETE,
SEP,
SELECT_ALL,
SEP,
"edit.find",
"edit.advanced_search",
SEP,
"edit.column_select",
"edit.snapshot",
SEP,
"edit.attach",
"form.clear",
SEP,
"app.preferences",
];
const ZOOM: &[&str] = &[ACTUAL_SIZE, PAGE_LEVEL, FIT_WIDTH, FIT_HEIGHT, "view.fit_visible", SEP, ZOOM_IN, ZOOM_OUT, SEP, "view.marquee_zoom"];
const NAVIGATION: &[&str] = &["view.focus_page_input", SEP, "view.previous_view", "view.next_view"];
const DISPLAY: &[&str] = &[
"view.layout.single",
"view.layout.two_up",
SEP,
"view.continuous",
"view.page_gaps",
"view.layout.cover",
SEP,
"view.fit_width_scrolling",
"view.fit_one_page",
];
const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"];
const PANES: &[&str] = &["view.side_panel", "view.tools_pane"];
const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"];
const MODES: &[&str] = &["view.full_screen", "view.read_mode"];
const PAGES: &[&str] = &[
"page.organize",
SEP,
"page.insert_blank",
"page.insert",
"page.replace",
"page.extract",
"page.split",
"page.duplicate",
"page.delete",
SEP,
"page.rotate",
"page.rotate_ccw",
"page.rotate_dialog",
"page.reverse",
SEP,
"page.crop",
"page.boxes",
"page.number",
SEP,
"bookmark.add",
"bookmark.from_structure",
];
/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar.
const FILL: &[(&str, &str)] = &[
("sign.fill.text", "Add text"),
("sign.fill.check", "Checkmark"),
("sign.fill.cross", "Cross"),
("sign.fill.dot", "Dot"),
("sign.fill.line", "Line"),
("sign.fill.date", "Date"),
("sign.fill.signature", "Signature"),
("sign.fill.initials", "Initials"),
];
const SIGN: &[&str] = &[
SEP,
"sign.fill.signature.change",
"sign.fill.initials.change",
"sign.fill.signature.remove",
"sign.fill.initials.remove",
SEP,
"sign.digital",
"sign.certify",
"sign.certify_invisible",
"form.add.signature",
SEP,
"sign.validate",
"sign.panel",
];
const WINDOW: &[&str] = &["view.next_tab", "view.previous_tab"];
const HELP: &[&str] =
&["help.shortcuts", SEP, "help.app_page", "help.github", "help.website", "help.discord", SEP, "help.check_updates", SEP, "help.about"];
/// A registry shortcut as a craft-keymap chord: `command` is ⌘ (Ctrl elsewhere) and `mac_ctrl`
/// the Control key on macOS. `None` for a key craft-keymap doesn't name.
pub(crate) fn chord(s: Shortcut) -> Option<Chord> {
let key = Key::parse(s.key)?;
Some(Chord::new(Mods { cmd: s.command, ctrl: s.mac_ctrl, alt: false, shift: s.shift }, key))
}
fn extra(id: &str) -> Option<&'static (&'static str, &'static str, Option<Shortcut>)> {
EXTRA.iter().find(|(e, ..)| *e == id)
}
/// The English label of an item's command, for the bar's mnemonics.
fn english_label(command: &CommandRef) -> Option<String> {
if let Some(spec) = commands::command(&command.id) {
return Some(spec.label.to_owned());
}
if let Some((_, label, _)) = extra(&command.id) {
return Some((*label).to_owned());
}
FILL.iter().find(|(id, _)| *id == command.id).map(|(_, label)| (*label).to_owned())
}
fn english(labeled: Labeled<'_>) -> Option<String> {
match labeled {
Labeled::Command(command) => english_label(command),
Labeled::Title(shown) => TITLES.iter().find(|t| tl!(t) == shown).map(|t| (*t).to_owned()),
}
}
fn translate(english: &'static str) -> String {
tl!(english).to_owned()
}
/// The layouts' own labels (About, Quit, Window…) through the app's translations.
pub(crate) fn layout_options() -> LayoutOptions<'static> {
LayoutOptions { label: &translate, ..LayoutOptions::new(APP_NAME) }
}
impl PdfCraftApp {
/// The app's menus for this frame, before the platform layout: every item's label, shortcut,
/// enabled and checked state as the app is now. A modal dialog doesn't grey anything here;
/// that is the [`Gate`]'s job ([`PdfCraftApp::menu_gate`]).
pub fn menu_bar(&self, ctx: &egui::Context) -> MenuBar {
let mut used: Vec<&str> = Vec::new();
let list = |ids: &[&'static str], used: &mut Vec<&'static str>| -> Vec<Node> {
ids.iter()
.map(|&id| {
if id == SEP {
return Node::Separator;
}
used.push(id);
self.menu_item(id, None)
})
.collect()
};
let file = {
let mut nodes = Vec::new();
for node in list(FILE, &mut used) {
// Open Recent is the live recent list; Create, Export and Protect follow Combine,
// Optimize and Close.
let recent = matches!(&node, Node::Item(it) if it.command.id == "file.open_recent");
if recent {
nodes.push(self.recent_menu());
continue;
}
let after = match &node {
Node::Item(it) => it.command.id.clone(),
_ => String::new(),
};
nodes.push(node);
match after.as_str() {
"page.combine" => nodes.push(Node::submenu(tl!("Create"), list(CREATE, &mut used))),
"optimize.advanced" => {
nodes.push(Node::submenu(tl!("Export"), list(EXPORT, &mut used)));
nodes.push(Node::Separator);
nodes.push(Node::submenu(tl!("Protect"), list(PROTECT, &mut used)));
}
_ => {}
}
}
nodes
};
// Edit ▸ Pages follows Select All.
let mut edit = Vec::new();
for node in list(EDIT, &mut used) {
let select_all = matches!(&node, Node::Item(it) if it.command.id == SELECT_ALL);
edit.push(node);
if select_all {
edit.push(Node::submenu(tl!("Pages"), list(PAGES, &mut used)));
}
}
let mut sign = vec![Node::submenu(
tl!("Fill & Sign"),
FILL.iter()
.map(|&(id, label)| {
used.push(id);
self.menu_item(id, Some(tl!(label)))
})
.collect(),
)];
sign.extend(list(SIGN, &mut used));
let window = list(WINDOW, &mut used);
let help = list(HELP, &mut used);
let mut view = vec![
Node::submenu(tl!("Zoom"), list(ZOOM, &mut used)),
Node::submenu(tl!("Rotate view"), list(&[ROTATE_CW, ROTATE_CCW], &mut used)),
Node::submenu(tl!("Page navigation"), list(NAVIGATION, &mut used)),
Node::submenu(tl!("Page display"), list(DISPLAY, &mut used)),
Node::Separator,
];
view.extend(list(PANES, &mut used));
view.push(Node::submenu(
tl!("Side panels"),
PANELS
.iter()
.map(|(_, name, label)| {
Item::new(CommandRef::with_params(PANEL, serde_json::json!({ "panel": name })), tl!(label)).radio(false).into()
})
.collect(),
));
view.extend(list(PANE_COMMANDS, &mut used));
view.push(Node::Separator);
view.extend(list(MODES, &mut used));
view.push(Node::submenu(tl!("Display theme"), list(THEMES, &mut used)));
view.push(Node::Separator);
view.push(Node::submenu(tl!("Tools"), self.tools_menu(&used)));
view.extend(list(&["view.palette"], &mut used));
let mut bar = MenuBar {
menus: vec![
Menu::new(tl!("File"), craft_menu::tidy(file)).role(MenuRole::File),
Menu::new(tl!("Edit"), craft_menu::tidy(edit)).role(MenuRole::Edit),
Menu::new(tl!("View"), craft_menu::tidy(view)).role(MenuRole::View),
Menu::new(tl!("Sign"), craft_menu::tidy(sign)),
Menu::new(tl!("Window"), window).role(MenuRole::Window),
Menu::new(tl!("Help"), craft_menu::tidy(help)).role(MenuRole::Help),
],
documents: self.menu_documents(),
};
bar.tag_item(&CommandRef::new("help.about"), ItemRole::About);
bar.tag_item(&CommandRef::new("app.preferences"), ItemRole::Settings);
craft_menu::apply_state(&mut bar, |command| self.menu_state(ctx, command));
bar
}
/// One item: a registry command with its live label (Undo names the edit) and shortcut, or a
/// menu-only one. Radio and check items get their kind here, their state from
/// [`PdfCraftApp::menu_state`].
fn menu_item(&self, id: &'static str, label: Option<&str>) -> Node {
let (english, shortcut) = match commands::command(id) {
Some(spec) => {
let current = commands::current_label(spec, &self.session, self.active_ids().map(|(_, doc)| doc));
(crate::i18n::menu_label(id, &current), spec.shortcut.or((id == "edit.copy").then_some(crate::commands::COPY)))
}
None => match extra(id) {
Some((_, label, shortcut)) => (tl!(label).to_owned(), *shortcut),
None => (id.to_owned(), None),
},
};
let mut item = Item::new(id, label.map_or(english, str::to_owned));
if let Some(c) = shortcut.and_then(chord) {
item = item.shortcut(c);
}
if matches!(id, "view.layout.single" | "view.layout.two_up") || THEMES.contains(&id) {
item = item.radio(false);
}
item.into()
}
/// File ▸ Open Recent: the recent files, newest first, then Clear Recent Files.
fn recent_menu(&self) -> Node {
let mut items: Vec<Node> = self
.recent
.iter()
.enumerate()
.map(|(index, r)| Item::new(CommandRef::with_params("file.open_recent", serde_json::json!({ "index": index })), r.name.clone()).into())
.collect();
if items.is_empty() {
items.push(self.menu_item(NO_RECENT, None));
} else {
items.push(Node::Separator);
items.push(self.menu_item("file.clear_recent", Some(tl!("Clear Recent Files"))));
}
let label = commands::command("file.open_recent").map_or("Open Recent", |c| c.label);
Node::submenu(crate::i18n::menu_label("file.open_recent", label), items)
}
/// View ▸ Tools: the All tools groups, each a submenu of its ready tools that no other menu
/// lists, sections apart.
fn tools_menu(&self, used: &[&str]) -> Vec<Node> {
TOOL_GROUPS
.iter()
.filter(|g| g.availability == Availability::Ready)
.map(|group| {
let mut nodes = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for section in group.sections {
nodes.push(Node::Separator);
for tool in section.items.iter().filter(|t| t.availability == Availability::Ready) {
let actions: &[&'static str] = if tool.actions.is_empty() { std::slice::from_ref(&tool.command) } else { tool.actions };
for &id in actions {
if commands::command(id).is_none() || used.contains(&id) || seen.contains(&id) {
continue;
}
seen.push(id);
let label = if tool.actions.is_empty() { Some(tl!(tool.label)) } else { None };
nodes.push(self.menu_item(id, label));
}
}
}
Node::submenu(tl!(group.label), nodes)
})
.collect()
}
/// The open documents, in tab order, for the Window menu.
fn menu_documents(&self) -> Vec<Document> {
(0..self.views.len())
.filter_map(|index| {
let name = crate::canvas::tab_label(self, index)?;
Some(Document::new(CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": index })), name).active(self.active == Some(index)))
})
.collect()
}
/// What may run from the menus now: a dialog, a prompt or the command palette holds the
/// window. Edit's text items stay with a focused text field (craft-menu's gate).
pub fn menu_gate(&self, ctx: &egui::Context) -> Gate {
Gate { modal: self.modal_open() || self.palette_open, recording_shortcut: false, text_editing: ctx.text_edit_focused() }
}
/// Whether a menu item's command can run now, and its checked or selected state.
pub fn menu_state(&self, ctx: &egui::Context, command: &CommandRef) -> ItemState {
let view = self.active.and_then(|i| self.views.get(i));
let organize = view.is_some_and(|v| v.organize);
let has_doc = view.is_some();
let enabled_id = |id: &str| commands::command(id).is_some_and(|s| self.command_enabled(s));
let index = param_index(command);
let text = ctx.text_edit_focused() && matches!(command.id.as_str(), "edit.undo" | "edit.redo" | "edit.copy" | CUT | PASTE | SELECT_ALL);
let enabled = text
|| match command.id.as_str() {
NO_RECENT => false,
"file.open_recent" => index.is_none_or(|i| i < self.recent.len()),
"file.clear_recent" => !self.recent.is_empty(),
DOCUMENT => index.is_some_and(|i| i < self.views.len()),
// macOS ⌘W closes the window when no document is open (#869).
"file.close" if !has_doc && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") => true,
"edit.copy" if organize => enabled_id("page.copy"),
"edit.copy" if view.is_some_and(|v| v.picked_image.is_some()) => {
self.active_ids().and_then(|(_, id)| self.session.get(id)).is_some_and(|d| d.allows_copying())
}
CUT => organize && enabled_id("page.cut"),
PASTE => organize && self.page_clipboard.is_some() && enabled_id("page.paste"),
SELECT_ALL | DELETE => has_doc,
ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => has_doc,
PANEL => panel(command).is_some(),
id => enabled_id(id),
};
let checked = match command.id.as_str() {
"view.layout.single" => Some(view.is_some_and(|v| v.layout == PageLayout::Single)),
"view.layout.two_up" => Some(view.is_some_and(|v| v.layout == PageLayout::TwoUp)),
"view.continuous" => Some(view.is_some_and(|v| v.continuous())),
"view.page_gaps" => Some(view.is_some_and(|v| v.show_page_gaps)),
"view.layout.cover" => Some(view.is_some_and(|v| v.cover)),
"view.full_screen" => Some(self.full_screen),
"view.read_mode" => Some(self.mode == crate::Mode::Read),
"view.side_panel" => Some(self.right.is_some()),
"view.tools_pane" => Some(self.left_open),
"view.theme.system" => Some(self.theme_preference == ThemePreference::System),
"view.theme.light" => Some(self.theme_preference == ThemePreference::Light),
"view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark),
PANEL => Some(panel(command).is_some_and(|p| self.right == Some(p))),
_ => None,
};
ItemState { enabled, checked }
}
/// Run a command chosen in either menu bar. Registry commands go through
/// [`PdfCraftApp::dispatch_command`], as their shortcuts do; the clipboard items act as
/// their keys would (⌘C is a Copy event, Delete a key press), so every place that handles the
/// key handles the click. A command that can't run now (a dialog opened since the menu was
/// drawn) does nothing.
pub fn run_menu_command(&mut self, ctx: &egui::Context, command: &CommandRef) {
// Both menu bars run Quit and Minimize themselves; Hide and Hide Others are AppKit's.
if Builtin::of(command).is_some() {
return;
}
if !self.menu_gate(ctx).allows_command(command) || !self.menu_state(ctx, command).enabled {
return;
}
let index = param_index(command);
let key = |key: egui::Key, modifiers: egui::Modifiers| {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })
};
match command.id.as_str() {
"file.open_recent" if index.is_some() => {
if let Some(path) = index.and_then(|i| self.recent.get(i)).map(|r| r.path.clone()) {
self.open_recent(&path);
}
}
DOCUMENT => {
if let Some(i) = index.filter(|&i| i < self.views.len()) {
self.active = Some(i);
}
}
"edit.copy" => self.menu_input.push(egui::Event::Copy),
CUT => self.menu_input.push(egui::Event::Cut),
PASTE => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste),
SELECT_ALL => self.menu_input.extend(key(egui::Key::A, egui::Modifiers::COMMAND)),
DELETE => self.menu_input.extend(key(egui::Key::Delete, egui::Modifiers::NONE)),
PANEL => self.choose_right_panel(panel(command)),
ACTUAL_SIZE | PAGE_LEVEL | FIT_WIDTH | FIT_HEIGHT | ZOOM_IN | ZOOM_OUT | ROTATE_CW | ROTATE_CCW => {
let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) else { return };
match command.id.as_str() {
ACTUAL_SIZE => v.set_zoom(1.0),
PAGE_LEVEL => v.fit = Fit::Page,
FIT_WIDTH => v.fit = Fit::Width,
FIT_HEIGHT => {
v.fit = Fit::Height;
v.goto = Some((v.current, 0.0));
}
ZOOM_IN => v.zoom_step(true),
ZOOM_OUT => v.zoom_step(false),
ROTATE_CW => v.rotate_view(true),
_ => v.rotate_view(false),
}
}
id => {
if let Some(spec) = commands::command(id) {
self.dispatch_command(ctx, spec.id);
}
}
}
ctx.request_repaint();
}
/// The menu bar drawn in the window (Windows, Linux, the web, and macOS when the native one
/// couldn't be installed), where the top bar starts. Runs the commands chosen.
pub(crate) fn menu_bar_ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let bar = craft_menu::layout(&self.menu_bar(&ctx), Target::current(), &layout_options()).bar;
let gate = self.menu_gate(&ctx);
// The mode tabs, search and buttons follow the bar in the same row: it takes its titles'
// width, less when the row is short, where the menus that don't fit move into its "⋯".
let fit = (ui.available_width() - ROW_AFTER_MENUS).max(MIN_MENUS_WIDTH);
let chosen = craft_menu::MenuBarUi::new(&bar)
.gate(gate)
.label(&translate)
.english(&english)
.id(egui::Id::unique("pdfcraft-menu-bar"))
.shrink_to_fit(fit)
.show(ui);
for command in chosen {
self.run_menu_command(&ctx, &command);
}
}
/// Install the native menu bar (macOS), once, from the eframe app creator. `false` when
/// AppKit refused it: the in-window bar stays.
#[cfg(target_os = "macos")]
pub fn install_native_menu(&mut self, ctx: &egui::Context) -> bool {
crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.language));
let bar = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options()).bar;
// The Window menu lists the open documents itself, so AppKit's window list stays off.
self.native_menu = craft_menu::mac::install_with(ctx, &bar, craft_menu::mac::MacOptions::default().window_list(false));
self.native_menu.is_some()
}
/// The native menu's clicks since the last frame, run like in-window ones.
pub(crate) fn native_menu_clicks(&mut self, ctx: &egui::Context) {
let gate = self.menu_gate(ctx);
let Some(native) = self.native_menu.as_mut() else { return };
native.set_gate(gate);
for command in native.take_clicks(ctx) {
self.run_menu_command(ctx, &command);
}
}
/// Bring the native menu in step with the app, after this frame's changes.
pub(crate) fn sync_native_menu(&mut self, ctx: &egui::Context) {
if self.native_menu.is_none() {
return;
}
let layout = craft_menu::mac_layout(&self.menu_bar(ctx), &layout_options());
let gate = self.menu_gate(ctx);
if let Some(native) = self.native_menu.as_mut() {
native.set_gate(gate);
for clash in native.sync(layout) {
log::info!("menu shortcut {:?} kept by {} over {} ({})", clash.shortcut, clash.command.id, clash.system, clash.resolution);
}
}
}
}
/// A command's `{"index": n}` parameter.
fn param_index(command: &CommandRef) -> Option<usize> {
command.params.get("index").and_then(serde_json::Value::as_u64).and_then(|i| usize::try_from(i).ok())
}
/// The side panel a [`PANEL`] command names.
fn panel(command: &CommandRef) -> Option<RightPanel> {
let name = command.params.get("panel").and_then(serde_json::Value::as_str)?;
PANELS.iter().find(|(_, n, _)| *n == name).map(|(p, ..)| *p)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::rc::Rc;
use craft_menu::{Backend, Dispatch, Event, ItemKind, NativeMenu};
use super::*;
const PDF: &[u8] = include_bytes!("../tests/data/form.pdf");
fn app(docs: &[&str]) -> (PdfCraftApp, egui::Context) {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
for name in docs {
app.open_bytes(name, None, PDF.to_vec()).unwrap();
}
(app, egui::Context::default())
}
fn linux(app: &PdfCraftApp, ctx: &egui::Context) -> MenuBar {
craft_menu::layout(&app.menu_bar(ctx), Target::Linux, &layout_options()).bar
}
fn item<'b>(bar: &'b MenuBar, command: &CommandRef) -> &'b Item {
bar.find(command).unwrap_or_else(|| panic!("{} is in the menus", command.key()))
}
#[test]
fn every_menu_command_is_listed_once_with_its_shortcut() {
let (app, ctx) = app(&["a.pdf"]);
let bar = linux(&app, &ctx);
let items = bar.items();
let mut keys: Vec<String> = items.iter().map(|it| it.command.key()).collect();
keys.sort();
let count = keys.len();
keys.dedup();
assert_eq!(keys.len(), count, "no command is listed twice");
for spec in commands::COMMANDS.iter().filter(|s| s.menu.is_some()) {
if spec.id == "file.open_recent" {
continue; // A submenu of the recent files.
}
let it = item(&bar, &CommandRef::new(spec.id));
let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(crate::commands::COPY));
assert_eq!(it.shortcut, shortcut.and_then(chord), "{} has its registry shortcut", spec.id);
}
// Every item runs a registry command or one of the menu's own.
for it in items {
let id = it.command.id.as_str();
assert!(
commands::command(id).is_some() || extra(id).is_some() || [PANEL, DOCUMENT].contains(&id) || Builtin::of(&it.command).is_some(),
"{id} runs something"
);
}
}
#[test]
fn check_and_radio_items_follow_the_app() {
let (mut app, ctx) = app(&["a.pdf"]);
let kind = |app: &PdfCraftApp, id: &str| item(&linux(app, &ctx), &CommandRef::new(id)).kind;
assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(true));
assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(false));
let continuous = app.views[0].continuous();
assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(continuous));
app.run_menu_command(&ctx, &CommandRef::new("view.layout.two_up"));
app.run_menu_command(&ctx, &CommandRef::new("view.continuous"));
assert_eq!(kind(&app, "view.layout.single"), ItemKind::Radio(false));
assert_eq!(kind(&app, "view.layout.two_up"), ItemKind::Radio(true));
assert_eq!(kind(&app, "view.continuous"), ItemKind::Check(!continuous));
let left = app.left_open;
assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(left));
app.run_menu_command(&ctx, &CommandRef::new("view.tools_pane"));
assert_eq!(kind(&app, "view.tools_pane"), ItemKind::Check(!left));
let bookmarks = CommandRef::with_params(PANEL, serde_json::json!({ "panel": "bookmarks" }));
app.run_menu_command(&ctx, &bookmarks);
assert_eq!(app.right, Some(RightPanel::Bookmarks));
assert_eq!(item(&linux(&app, &ctx), &bookmarks).kind, ItemKind::Radio(true));
assert_eq!(kind(&app, "view.side_panel"), ItemKind::Check(true));
}
#[test]
fn a_dialog_greys_document_commands_and_drops_their_clicks() {
let (mut app, ctx) = app(&["a.pdf"]);
let rotate = CommandRef::new("page.rotate");
assert!(app.menu_gate(&ctx).allows_command(&rotate));
app.dialog = Some(crate::Dialog::About);
assert!(!app.menu_gate(&ctx).allows_command(&rotate), "the gate greys the menus under a dialog");
let doc = app.views[0].id;
let before = app.session.get(doc).unwrap().info.pages[0].rotation;
app.run_menu_command(&ctx, &rotate);
assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, before, "a click that arrives anyway does nothing");
// Quit still asks the window to close, which asks about unsaved changes.
assert!(app.menu_gate(&ctx).allows_command(&Builtin::Quit.command()));
}
#[test]
fn recent_files_and_open_documents_are_listed_once_each() {
let (mut app, ctx) = app(&["a.pdf", "b.pdf"]);
app.recent = ["one.pdf", "two.pdf"].map(|n| crate::RecentFile { name: n.into(), path: format!("/nowhere/{n}"), pages: 1, size: 0 }).into();
let bar = linux(&app, &ctx);
for (i, name) in ["one.pdf", "two.pdf"].into_iter().enumerate() {
assert_eq!(item(&bar, &CommandRef::with_params("file.open_recent", serde_json::json!({ "index": i }))).label, name);
}
let window = bar.menu(MenuRole::Window).unwrap();
let documents: Vec<&str> = window
.children
.iter()
.filter_map(|n| match n {
Node::Item(it) if it.command.id == DOCUMENT => Some(it.label.as_str()),
_ => None,
})
.collect();
assert_eq!(documents, ["a.pdf", "b.pdf"], "each open document once, in tab order");
app.run_menu_command(&ctx, &CommandRef::with_params(DOCUMENT, serde_json::json!({ "index": 0 })));
assert_eq!(app.active, Some(0));
// The macOS layout lists them once too.
let mac = craft_menu::mac_layout(&app.menu_bar(&ctx), &layout_options()).bar;
let listed = mac.items().iter().filter(|it| it.command.id == DOCUMENT).count();
assert_eq!(listed, 2);
app.recent.clear();
let bar = linux(&app, &ctx);
assert!(!item(&bar, &CommandRef::new(NO_RECENT)).enabled, "an empty list shows a greyed placeholder");
assert!(bar.find(&CommandRef::new("file.clear_recent")).is_none());
}
/// A native backend that reports what the test queues, through the driver's dispatch as the
/// macOS backend does.
#[derive(Clone, Default)]
struct FakeMenu(Rc<RefCell<(Option<Dispatch>, Vec<Event>)>>);
impl FakeMenu {
fn click(&self, command: &CommandRef) {
let mut state = self.0.borrow_mut();
let event = state.0.as_ref().map(|d| d.chosen(command, None, false, None));
state.1.extend(event);
}
}
impl Backend for FakeMenu {
fn sync(&mut self, _bar: &MenuBar) {}
fn drain(&mut self) -> Vec<Event> {
std::mem::take(&mut self.0.borrow_mut().1)
}
fn attach(&mut self, dispatch: Dispatch) {
self.0.borrow_mut().0 = Some(dispatch);
}
}
#[test]
fn a_native_menu_click_runs_its_command_once() {
let (mut app, ctx) = app(&["a.pdf"]);
let fake = FakeMenu::default();
app.native_menu = Some(NativeMenu::new(Box::new(fake.clone())));
let doc = app.views[0].id;
let before = app.session.get(doc).unwrap().info.pages[0].rotation;
fake.click(&CommandRef::new("page.rotate"));
let mut raw = egui::RawInput::default();
app.prepare_raw_input(&ctx, &mut raw);
app.native_menu_clicks(&ctx);
app.native_menu_clicks(&ctx);
assert_eq!(app.session.get(doc).unwrap().info.pages[0].rotation, (before + 90) % 360, "rotated once");
}
}
-4
View File
@@ -213,10 +213,6 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response
if elided { resp.on_hover_text(placeholder) } else { resp }
}
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
ui.add(egui::Button::new(label).shortcut_text(shortcut))
}
/// Corner radius of a checkbox's box. With the widgets' radius (`Tokens::radius`, 6) egui's 14 px
/// box is a circle, the shape of a radio button, and View ▸ Page display has both.
const CHECKBOX_RADIUS: u8 = 3;
+4
View File
@@ -530,6 +530,10 @@ fn combine_follows_the_order_shown_in_the_grid() {
#[test]
fn long_and_unusual_names_are_shortened_on_the_card_but_kept_whole() {
if crate::support::skip_without_glyphs("long_and_unusual_names_are_shortened_on_the_card_but_kept_whole", "en", "日本語のファイル名.pdf")
{
return;
}
let long = "Überweisungsbestätigung für das dritte Quartal 2026 – Endfassung (unterschrieben).pdf";
let mut h = grid_of(vec![(long, fixture(1)), ("日本語のファイル名.pdf", fixture(1)), ("a&b <c> 'd'.pdf", fixture(1))], vec2(1400.0, 900.0));
// The card's label is the whole name; only the drawing is shortened.
+19 -36
View File
@@ -293,15 +293,14 @@ fn palette_arrow_keys_select_results_and_enter_runs_the_selection() {
assert_eq!(app.session.get(app.views[0].id).unwrap().info.pages[0].rotation, 270);
}
/// Check that choosing Delete pages from the Pages menu reduces the document page count.
/// 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();
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("Pages").hover();
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Pages").hover();
h.run_steps(3);
h.get_by_label_contains("Delete pages").click();
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);
@@ -327,14 +326,11 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
}
});
h.run_steps(4);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("File").hover();
h.run_steps(3);
h.get_by_label("Open Recent").hover();
support::open_menu(&mut h, "File");
support::menu_row(&h, "Open Recent").hover();
h.run_steps(3);
let original = h.state().views[0].id;
h.get_by_label_contains("recent.pdf").click();
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();
@@ -348,10 +344,7 @@ fn the_open_recent_menu_lists_files_and_opens_one() {
#[test]
fn the_open_recent_menu_is_disabled_while_the_list_is_empty() {
let mut h = harness();
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("File").hover();
h.run_steps(3);
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"
@@ -373,19 +366,13 @@ fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() {
app
});
h.run_steps(4);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("File").hover();
support::open_menu(&mut h, "File");
support::menu_row(&h, "Open Recent").hover();
h.run_steps(3);
h.get_by_label("Open Recent").hover();
h.run_steps(3);
h.get_by_label("Clear Recent Files").click();
support::menu_row(&h, "Clear Recent Files").click();
h.run_steps(3);
assert!(h.state().recent.is_empty(), "the list is empty");
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("File").hover();
h.run_steps(3);
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");
}
@@ -458,13 +445,13 @@ fn localized_shortcuts_update_immediately_and_keep_command_ids_and_key_bindings(
assert!(!doc.dirty);
}
/// Menu ▸ View open, with egui told the app runs on `os`.
/// 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.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View").hover();
support::open_menu(&mut h, "View");
support::menu_row(&h, "Zoom").hover();
h.run_steps(3);
h
}
@@ -473,21 +460,14 @@ fn view_menu(os: OperatingSystem) -> Harness<'static, PdfCraftApp> {
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("Actual size Ctrl+1");
h.get_by_label("Zoom to page level Ctrl+0");
h.get_by_label("Rotate view counterclockwise Ctrl+Shift+−");
h.get_by_label("Previous view Ctrl+[");
h.get_by_label("Find tools and commands… Ctrl+K");
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");
h.get_by_label("Fit visible Ctrl+3");
let h = view_menu(OperatingSystem::Mac);
h.get_by_label("Actual size ⌘1");
h.get_by_label("Fit visible ⌘3");
h.get_by_label("Rotate view clockwise (⇧⌘+)");
assert_eq!(h.query_all_by_label_contains("Ctrl+").count(), 0, "no Ctrl+ on macOS");
}
@@ -623,6 +603,9 @@ fn palette_ime_idle_frames_and_reopen_do_not_interrupt_composition() {
#[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");
+6
View File
@@ -335,6 +335,9 @@ fn a_picture_on_the_clipboard_is_pasted_onto_the_page() {
/// written to the page as `?` (or lost).
#[test]
fn text_the_standard_fonts_cant_draw_stays_in_the_editor() {
if crate::support::skip_without_glyphs("text_the_standard_fonts_cant_draw_stays_in_the_editor", "en", "Hello 世界") {
return;
}
let _gpu = gpu();
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
@@ -372,6 +375,9 @@ fn text_the_standard_fonts_cant_draw_stays_in_the_editor() {
/// is left blocking Save out of sight.
#[test]
fn a_kept_draft_goes_with_its_deleted_page() {
if crate::support::skip_without_glyphs("a_kept_draft_goes_with_its_deleted_page", "en", "日本語") {
return;
}
let mut h = harness();
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 }));
h.run_steps(2);
+39 -15
View File
@@ -504,6 +504,9 @@ fn language_switch_preserves_document_and_command_ids() {
assert_eq!(documents[0]["dirty"], true);
let commands = ok(&mut h, &c, "ui.commands", json!({}));
for code in ["ja", "zh-hans", "fr", "de", "uk", "lt", "en"] {
if crate::support::skip_without_glyphs(&format!("language_switch_preserves_document_and_command_ids ({code})"), code, "") {
continue;
}
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code }));
h.run_steps(2);
let state = ok(&mut h, &c, "ui.state", json!({}));
@@ -512,12 +515,16 @@ fn language_switch_preserves_document_and_command_ids() {
assert_eq!(ok(&mut h, &c, "ui.commands", json!({})), commands);
let lang = i18n::Lang::from_code(code).unwrap();
h.get_by_label(i18n::tr(lang, "Menu")).click();
// A title without an ASCII letter shows its mnemonic after it ("ファイル(F)").
let file = i18n::tr(lang, "File");
h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(file))).click();
h.run_steps(2);
h.get_by_label(i18n::tr(lang, "File")).hover();
h.run_steps(3);
h.get_by_label_contains(i18n::tr(lang, "Open…"));
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
// The mnemonic goes before an ellipsis: "開く(O)…".
let open = i18n::tr(lang, "Open…").trim_end_matches('…');
let open_row = |n: &egui_kittest::kittest::AccessKitNode<'_>| {
n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(open))
};
assert!(h.query_all_by(open_row).next().is_some(), "File lists Open… in {code}");
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
h.run_steps(2);
}
@@ -908,6 +915,9 @@ fn state_and_view_options() {
#[test]
fn japanese_controls_and_search_keep_command_ids() {
if crate::support::skip_without_glyphs("japanese_controls_and_search_keep_command_ids", "ja", "") {
return;
}
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja");
@@ -1002,20 +1012,22 @@ fn ukrainian_preferences_and_search_keep_command_ids() {
#[test]
fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
if crate::support::skip_without_glyphs("preferences_menu_and_shortcut_allow_switching_interface_languages", "ja", "") {
return;
}
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
ok(&mut h, &c, "ui.click", json!({ "label": "メニュー" }));
// The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic
// ("編集(E)", "環境設定(P)…"), so match by the start of the label.
let menu_row = |text: &'static str| {
move |n: &egui_kittest::kittest::AccessKitNode<'_>| {
n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text))
}
};
h.get_by(menu_row("編集")).click();
h.run_steps(2);
// The open menu's Edit entry and the left panel's Edit tool share the visible label. Pick
// the popup entry by its id, not by its visible text.
let edits = ok(&mut h, &c, "ui.inspect", json!({ "query": "編集", "role": "Button" }));
// The tool button reports its toggle state; the popup's menu button does not.
let edit = edits["widgets"].as_array().unwrap().iter().find(|w| w.get("toggled").is_none()).expect("the Edit menu entry");
ok(&mut h, &c, "ui.click", json!({ "id": edit["id"] }));
h.get_by(menu_row("環境設定")).click();
h.run_steps(2);
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" }));
let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item");
ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] }));
for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
{
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current }));
@@ -1044,6 +1056,9 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
#[test]
fn japanese_dialogs_errors_and_custom_action_names() {
if crate::support::skip_without_glyphs("japanese_dialogs_errors_and_custom_action_names", "ja", "") {
return;
}
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "ja"}));
for (dialog, label) in [
@@ -1084,6 +1099,9 @@ fn japanese_dialogs_errors_and_custom_action_names() {
#[test]
fn simplified_chinese_about_tabs_and_credit_controls() {
if crate::support::skip_without_glyphs("simplified_chinese_about_tabs_and_credit_controls", "zh-hans", "") {
return;
}
let (mut h, c) = harness();
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"}));
@@ -1113,6 +1131,9 @@ fn simplified_chinese_about_tabs_and_credit_controls() {
#[test]
fn simplified_chinese_signature_prompts_and_errors_keep_document_state() {
if crate::support::skip_without_glyphs("simplified_chinese_signature_prompts_and_errors_keep_document_state", "zh-hans", "") {
return;
}
let (mut h, c) = harness();
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
ok(&mut h, &c, "ui.set", json!({"key": "language", "value": "zh-hans"}));
@@ -1721,6 +1742,9 @@ fn control_quit_retains_inactive_fill_sign_typing_after_cancel() {
#[test]
fn control_quit_retains_blocked_content_typing_after_cancel() {
if crate::support::skip_without_glyphs("control_quit_retains_blocked_content_typing_after_cancel", "en", "Keep 世界") {
return;
}
let (mut h, c) = harness();
ok(&mut h, &c, "ui.command", json!({"id":"edit.text"}));
h.run_steps(3);
+7
View File
@@ -133,6 +133,13 @@ fn month_names_follow_the_date_language_or_else_the_interface_language() {
#[test]
fn a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first() {
if crate::support::skip_without_glyphs(
"a_date_the_pdf_cannot_hold_is_refused_with_a_warning_first",
"en",
"日曜日月曜日火曜日水曜日木曜日金曜日土曜日",
) {
return;
}
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();
+9 -8
View File
@@ -1087,6 +1087,9 @@ fn save_prompt_fits_the_smallest_window_whatever_the_name() {
let size = egui::vec2(820.0, 520.0);
for name in names {
let start: String = name.chars().take(10).collect();
if support::skip_without_glyphs(&format!("save_prompt_fits_the_smallest_window_whatever_the_name ({start}…)"), "en", &name) {
continue;
}
let mut h = Harness::builder().with_size(size).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
@@ -1368,13 +1371,8 @@ fn edit_menu_names_the_step_to_undo() {
let mut h = harness(2, |app| {
app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 });
});
h.get_by_label("Menu").click();
h.run_steps(2);
// The popup's Edit entry and the left panel's Edit tool carry the same visible label. The
// tool reports a toggle state; the menu entry does not.
h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover(); // submenus open on hover; their labels carry the arrow
h.run_steps(3);
h.get_by_label_contains("Undo Rotate page").click(); // the label includes the shortcut
support::open_menu(&mut h, "Edit");
support::menu_row(&h, "Undo Rotate page").click();
h.run_steps(3);
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
@@ -3575,6 +3573,9 @@ fn an_applied_paragraph_keeps_its_edited_look_until_the_page_is_redrawn() {
/// as a form field does; the editor used to close with the error and the typing was lost.
#[test]
fn a_paragraph_whose_edit_fails_stays_open_with_its_typing() {
if crate::support::skip_without_glyphs("a_paragraph_whose_edit_fails_stays_open_with_its_typing", "en", "Top ก") {
return;
}
let bytes = source_font_fixture_with_text("BT /F1 24 Tf 20 220 Td (Top) Tj ET BT /F2 24 Tf 20 120 Td (Bottom) Tj ET");
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
@@ -3700,7 +3701,7 @@ fn editing_existing_text_in_place() {
/// paragraph editor and retypes it as "Chapter One", leaving the editor open.
fn edit_tab_with_an_open_paragraph(h: &mut Harness<'static, PdfCraftApp>) {
use pdfcraft_ui_egui::{Mode, QuickTool};
h.get_by_label("Edit").click();
support::outside_menus(h, "Edit").click();
h.run_steps(3);
assert_eq!((h.state().mode, h.state().quick_tool), (Mode::Edit, QuickTool::EditText));
open_the_first_line(h);
+2 -1
View File
@@ -1,5 +1,6 @@
//! 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;
@@ -127,7 +128,7 @@ fn backgrounds_and_watermarks_from_a_file() {
let mut h = harness();
h.state_mut().execute("edit.background");
h.run_steps(2);
h.get_by_label("File").click();
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");
+3
View File
@@ -963,6 +963,9 @@ impl Drop for AttachmentDir {
#[test]
fn attachment_save_preserves_bytes_metadata_and_selected_destination() {
if crate::support::skip_without_glyphs("attachment_save_preserves_bytes_metadata_and_selected_destination", "en", "user chosen 資料.bin") {
return;
}
let dir = AttachmentDir::new();
let chosen = dir.0.join("user chosen 資料.bin");
let original = attachment_fixture();
+5 -5
View File
@@ -813,16 +813,16 @@ fn actions_tab_shows_a_chain_and_keeps_it_when_the_first_action_is_replaced() {
};
open(&mut h);
h.get_by_label("Run a JavaScript, then 1 more action");
// The first Edit is the menu bar's; the rows' follow (Mouse Up, On Blur).
assert_eq!(h.get_all_by_label("Edit").count(), 3);
h.get_all_by_label("Edit").nth(1).unwrap().click();
// The menu bar's Edit menu and the Edit tab come first; the rows' follow (Mouse Up, On Blur).
assert_eq!(h.get_all_by_label("Edit").count(), 4);
h.get_all_by_label("Edit").nth(2).unwrap().click();
h.run_steps(2);
assert_eq!(h.state().field_props.as_ref().unwrap().editing, Some(FieldTrigger::MouseUp));
// While one script is open, no other Edit can overwrite it.
assert!(h.get_all_by_label("Edit").skip(1).all(|n| n.accesskit_node().is_disabled()));
assert!(h.get_all_by_label("Edit").skip(2).all(|n| n.accesskit_node().is_disabled()));
h.get_by_label("Discard").click();
h.run_steps(2);
assert!(h.get_all_by_label("Edit").skip(1).all(|n| !n.accesskit_node().is_disabled()));
assert!(h.get_all_by_label("Edit").skip(2).all(|n| !n.accesskit_node().is_disabled()));
// Replace the Mouse Up script with a link: the chained link stays after it.
{
let d = h.state_mut().field_props.as_mut().unwrap();
+9 -8
View File
@@ -1,5 +1,6 @@
//! Use a certificate in the real shell: draw a signature, create a digital ID, sign, validate.
use crate::support;
use egui::{Pos2, pos2};
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
@@ -128,14 +129,14 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() {
field(&h, "Digital ID password").click();
h.run_steps(1);
field(&h, "Digital ID password").type_text("nope");
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(3);
h.get_by_label("The password is incorrect.");
h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into();
field(&h, "Reason").click();
h.run_steps(1);
field(&h, "Reason").type_text("I approve");
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
let s = h.state();
@@ -275,7 +276,7 @@ fn certifying_without_a_visible_signature() {
h.get_by_label("Certify as \"Test Signer EC\"");
h.get_by_label("Permitted actions after certifying");
h.state_mut().sign_draft.as_mut().unwrap().password = "test".into();
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
let s = h.state();
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
@@ -361,7 +362,7 @@ fn clicking_an_empty_signature_field_signs_it() {
d.password = "test".into();
}
h.run_steps(2);
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
let s = h.state();
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
@@ -398,7 +399,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
h.get_by_label("Fill & Sign signature").click();
h.run_steps(2);
assert_eq!(h.state().sign_draft.as_ref().unwrap().graphic, SignGraphic::FillSign);
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
let s = h.state();
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
@@ -418,7 +419,7 @@ fn the_fill_and_sign_signature_or_an_image_shows_in_a_digital_signature() {
h.state_mut().sign_draft.as_mut().unwrap().graphic = SignGraphic::Image(image.clone());
h.run_steps(2);
h.get_by_label("Choose image…");
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
let s = h.state();
assert_eq!(s.sign_draft.as_ref().and_then(|d| d.error.clone()), None);
@@ -534,7 +535,7 @@ fn windows_store_identity_does_not_ask_for_a_file_password() {
h.get_by_label("Sign as \"Store signer\"");
assert_eq!(h.query_all_by_label("Digital ID password").count(), 0);
h.get_by_label("The key is in the Windows certificate store, which may ask to allow PdfCraft to use it.");
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(Dialog::Sign));
assert!(h.state().sign_draft.as_ref().unwrap().error.as_ref().unwrap().contains("Windows certificate store"));
@@ -679,7 +680,7 @@ fn signing_preserves_destination_opening_attributes() {
let d = h.state_mut().sign_draft.as_mut().unwrap();
(d.selected, d.step, d.password) = (Some(entry), SignStep::SignAs, "test".into());
h.run_steps(2);
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
assert!(h.state().session.get(h.state().views[0].id).unwrap().signatures.iter().any(|s| s.signed));
assert_eq!(xattr::get(&dest, "com.apple.LaunchServices.OpenWith").unwrap(), Some(b"existing binding".to_vec()));
+60 -2
View File
@@ -1,7 +1,12 @@
//! State-based waits for native preparation, which completes between UI frames, and pointer
//! input in the frames the platform delivers it in ([`input`]).
//! input in the frames the platform delivers it in ([`input`]), and the skip for tests whose text
//! the interface fonts cannot draw ([`skip_without_glyphs`]).
use std::collections::BTreeSet;
use egui::FontId;
use egui::epaint::text::{Fonts, TextOptions};
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
use pdfcraft_ui_egui::{PdfCraftApp, i18n, theme};
pub mod input;
@@ -45,3 +50,56 @@ pub fn wait_until(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl
h.state().toast
);
}
/// Open the in-window menu bar's `title` menu (File, Edit, View…).
pub fn open_menu(h: &mut Harness<'static, PdfCraftApp>, title: &str) {
use egui_kittest::kittest::Queryable;
h.get_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(title)).click();
h.run_steps(2);
}
/// The menu row labelled `label` in an open menu.
pub fn menu_row<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> {
use egui_kittest::kittest::Queryable;
let label = label.to_owned();
h.get_by(move |n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str()))
}
/// The widget labelled `label` outside the menu bar, whose titles (File, Edit, Sign…) may share it.
pub fn outside_menus<'h>(h: &'h Harness<'static, PdfCraftApp>, label: &str) -> egui_kittest::Node<'h> {
use egui_kittest::kittest::Queryable;
let label = label.to_owned();
h.get_by(move |n| n.role() != egui::accesskit::Role::MenuItem && n.label().as_deref() == Some(label.as_str()))
}
/// Whether the test `name` must skip because the interface fonts lack a glyph for a character it
/// draws: one in `text` or in the catalog of the interface `language`. egui 0.37 test harnesses
/// panic on a missing glyph, and Japanese, Chinese and Thai faces come only from the optional
/// craft-fonts build input or a face installed on this machine. Prints the skip line.
///
/// The fonts are those the app installs for `language` and for the default CJK preference: a
/// switch to Chinese installs its fonts for the next frame, so the first frame in Chinese draws
/// with the fonts of the language before it.
///
/// A build that requires craft-fonts (`CRAFT_FONTS_REQUIRED=1`, as the CI gate sets) never skips:
/// a missing glyph there is a failure.
pub fn skip_without_glyphs(name: &str, language: &str, text: &str) -> bool {
if std::env::var("CRAFT_FONTS_REQUIRED").as_deref() == Ok("1") {
return false;
}
let catalog = i18n::LANGUAGES.iter().find(|l| l.code == language).map_or("", |l| l.source);
let translations = catalog.lines().filter(|l| !l.starts_with('#')).filter_map(|l| l.split('\t').nth(2));
let chars: BTreeSet<char> = std::iter::once(text).chain(translations).flat_map(str::chars).filter(|c| !c.is_control()).collect();
let cjk = theme::CjkPreference::for_language(language);
let missing = [theme::CjkPreference::Default, cjk].into_iter().find_map(|cjk| {
let defs = theme::installed_font_definitions(cjk);
let families: Vec<FontId> = defs.families.keys().map(|f| FontId::new(13.0, f.clone())).collect();
let mut fonts = Fonts::new(TextOptions::default(), defs);
chars.iter().copied().find(|c| families.iter().any(|id| !fonts.has_glyphs(id, &c.to_string())))
});
let Some(missing) = missing else {
return false;
};
eprintln!("skipping {name}: no interface font draws {missing:?} (set CRAFT_FONTS_DIR to a craft-fonts checkout with a face for it)");
true
}
+5 -10
View File
@@ -192,17 +192,14 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool {
/// 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::{NodeT, Queryable};
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.
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_all_by_label("Edit").find(|n| n.accesskit_node().toggled().is_none()).expect("the Edit menu entry").hover();
h.run_steps(3);
h.get_by_label_contains("Copy").click();
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();
@@ -229,15 +226,13 @@ fn selected_text_copy_edit_and_context_menus() {
#[test]
fn escape_closes_an_open_menu_before_it_clears_the_selection() {
// #3535: one Escape closed the main menu and dropped the selection with it.
use egui_kittest::kittest::Queryable;
// #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"));
h.get_by_label("Menu").click();
h.run_steps(2);
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);
+2 -5
View File
@@ -36,12 +36,9 @@ fn click_toolbar(h: &mut Harness<'static, PdfCraftApp>, label: &str) {
}
fn open_view_menu(h: &mut Harness<'static, PdfCraftApp>) {
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View").hover();
h.run_steps(3);
crate::support::open_menu(h, "View");
assert!(h.query_by_label("Switch light / dark theme").is_none());
h.get_by_label("Display theme").hover();
crate::support::menu_row(h, "Display theme").hover();
h.run_steps(3);
for choice in ["Use system setting", "Light gray", "Dark gray"] {
h.get_by_label(choice);
+2 -1
View File
@@ -3,6 +3,7 @@
//! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the
//! self-signed root) differs from its `certificate` (the signer); the click must trust the signer.
use crate::support;
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::SignatureStatus;
@@ -58,7 +59,7 @@ fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() {
d.password = "test".into();
}
h.run_steps(2);
h.get_by_label("Sign").click();
support::outside_menus(&h, "Sign").click();
h.run_steps(6);
// The signature validates as Unknown and carries a two-certificate chain whose top (the root)
+5 -5
View File
@@ -279,7 +279,7 @@ fn expanding_all_tools_from_read_mode_restores_the_right_panel() {
fn expanding_all_tools_from_edit_puts_edit_text_down() {
use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.get_by_label("Edit").click();
support::outside_menus(&h, "Edit").click();
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::EditText);
h.state_mut().set_option("tools", "full").unwrap();
@@ -1640,7 +1640,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
use pdfcraft_ui_egui::{Mode, QuickTool};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().quick_tool, QuickTool::Select);
h.get_by_label("Edit").click();
support::outside_menus(&h, "Edit").click();
h.run_steps(3);
assert_eq!(h.state().mode, Mode::Edit);
assert_eq!(h.state().quick_tool, QuickTool::EditText);
@@ -1649,7 +1649,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
assert_eq!(h.state().mode, Mode::AllTools);
assert_eq!(h.state().quick_tool, QuickTool::Select);
// Another tool picked inside Edit is not taken away by leaving it.
h.get_by_label("Edit").click();
support::outside_menus(&h, "Edit").click();
h.run_steps(3);
h.state_mut().quick_tool = QuickTool::Hand;
h.get_by_label("Convert").click();
@@ -1663,7 +1663,7 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
fn organize_pages_from_the_edit_panel_opens_its_panel() {
use pdfcraft_ui_egui::LeftPanel;
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.get_by_label("Edit").click();
support::outside_menus(&h, "Edit").click();
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::Tool("edit"));
assert!(
@@ -1681,7 +1681,7 @@ fn organize_pages_from_the_edit_panel_opens_its_panel() {
#[test]
fn the_edit_panel_keeps_the_add_update_remove_menu() {
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.get_by_label("Edit").click();
support::outside_menus(&h, "Edit").click();
h.run_steps(3);
h.get_by_label("Watermark").click();
h.run_steps(3);
+7 -11
View File
@@ -1033,9 +1033,8 @@ fn view_menu_page_display_uses_the_same_commands() {
use egui_kittest::kittest::Queryable;
let mut h = harness(&[]);
h.run_steps(4);
h.get_by_label("Menu").click();
h.run_steps(2);
h.get_by_label("View").hover();
crate::support::open_menu(&mut h, "View");
crate::support::menu_row(&h, "Page display").hover();
h.run_steps(3);
h.get_by_label("Single page").click();
h.run_steps(4);
@@ -2011,17 +2010,14 @@ fn long_view_menu_fits_a_short_window() {
app
});
h.run_steps(8);
h.get_by_label("Menu").click();
crate::support::open_menu(&mut h, "View");
h.run_steps(2);
h.get_by_label("View").hover();
h.run_steps(4);
let before = h.get_by_label("Side panels").rect().bottom();
h.get_by_label("Fit to height ").hover();
crate::support::menu_row(&h, "Rotate view").hover();
h.event(wheel(MouseWheelUnit::Point, -1000.0, TouchPhase::Move));
h.run_steps(6);
let after = h.get_by_label("Side panels").rect().bottom();
assert!(after < before && after <= 400.0, "menu did not scroll into view: {before} -> {after}");
h.get_by_label("Side panels").hover();
let after = crate::support::menu_row(&h, "Side panels").rect().bottom();
assert!(after <= 400.0, "the menu scrolls Side panels into view: {after}");
crate::support::menu_row(&h, "Side panels").hover();
h.run_steps(4);
h.get_all_by_label("Bookmarks").last().unwrap().click();
h.run_steps(4);