mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 07:39:08 +00:00
M2: sync validated CJK viewer PR with latest main
This commit is contained in:
commit
d865886d39
26 files changed
+1079
-329
No files matched your search
Generated
+36
-13
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ¤t), 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user