diff --git a/Cargo.lock b/Cargo.lock index 54be313cc6..3c713ba5cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 4d1d5e5f6c..8eb38b29af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 15ec5d6f68..966af354d1 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -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 diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index ab6ebf830c..ee8ae152cc 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -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 { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 5279f56248..804b5ea22a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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" diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index b977112460..b81ac2d58c 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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()); diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index a27a25ff82..26fad6f77c 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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, count: usize, forward: bool) -> Option = 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::*; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79f46bee7b..3446bcd26e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, + /// The native menu bar (macOS), while it is installed; the in-window bar shows otherwise. + pub(crate) native_menu: Option, + /// Input a menu click stands for (⌘C, Delete…), added to the next frame's input in `logic`. + pub(crate) menu_input: Vec, /// The dialog seen at the last check, and a counter bumped whenever it changes (see /// [`Self::dialog_epoch`]). dialog_seen: Option, @@ -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) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs new file mode 100644 index 0000000000..786840b650 --- /dev/null +++ b/crates/ui-egui/src/menus.rs @@ -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)] = &[ + (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 { + 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)> { + 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 { + 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 { + 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 { + 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 = 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 { + 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 { + (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 { + 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 { + 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 = 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, Vec)>>); + + 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 { + 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"); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 01cab03314..23513cc973 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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; diff --git a/crates/ui-egui/tests/it/combine_grid.rs b/crates/ui-egui/tests/it/combine_grid.rs index c6f2e3e721..0339e0b6bb 100644 --- a/crates/ui-egui/tests/it/combine_grid.rs +++ b/crates/ui-egui/tests/it/combine_grid.rs @@ -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 'd'.pdf", fixture(1))], vec2(1400.0, 900.0)); // The card's label is the whole name; only the drawing is shortened. diff --git a/crates/ui-egui/tests/it/commands.rs b/crates/ui-egui/tests/it/commands.rs index ff82820148..1e3c36216f 100644 --- a/crates/ui-egui/tests/it/commands.rs +++ b/crates/ui-egui/tests/it/commands.rs @@ -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"); diff --git a/crates/ui-egui/tests/it/content.rs b/crates/ui-egui/tests/it/content.rs index 0353a7d2b3..4a7927f692 100644 --- a/crates/ui-egui/tests/it/content.rs +++ b/crates/ui-egui/tests/it/content.rs @@ -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); diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index e18c7bf47a..e2b09931ea 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -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); diff --git a/crates/ui-egui/tests/it/date_format.rs b/crates/ui-egui/tests/it/date_format.rs index 0606e021de..c2dadb28d3 100644 --- a/crates/ui-egui/tests/it/date_format.rs +++ b/crates/ui-egui/tests/it/date_format.rs @@ -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(); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index 9bf7f70813..e5e104d674 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -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); diff --git a/crates/ui-egui/tests/it/marks.rs b/crates/ui-egui/tests/it/marks.rs index 8861310115..38ffdffd39 100644 --- a/crates/ui-egui/tests/it/marks.rs +++ b/crates/ui-egui/tests/it/marks.rs @@ -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"); diff --git a/crates/ui-egui/tests/it/pickers.rs b/crates/ui-egui/tests/it/pickers.rs index 6aa5515527..f9e46c5f2f 100644 --- a/crates/ui-egui/tests/it/pickers.rs +++ b/crates/ui-egui/tests/it/pickers.rs @@ -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(); diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index 806fa7153e..8d565ab696 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -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(); diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 0050f82b19..62d97d4e58 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -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())); diff --git a/crates/ui-egui/tests/it/support/mod.rs b/crates/ui-egui/tests/it/support/mod.rs index aa96ab20dc..f419bb3296 100644 --- a/crates/ui-egui/tests/it/support/mod.rs +++ b/crates/ui-egui/tests/it/support/mod.rs @@ -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 = 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 = 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 +} diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 7f7358bfeb..e480f1c598 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -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); diff --git a/crates/ui-egui/tests/it/theme.rs b/crates/ui-egui/tests/it/theme.rs index e5e930a971..a02c5c05ca 100644 --- a/crates/ui-egui/tests/it/theme.rs +++ b/crates/ui-egui/tests/it/theme.rs @@ -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); diff --git a/crates/ui-egui/tests/it/trust_signer.rs b/crates/ui-egui/tests/it/trust_signer.rs index 27bd2f3c9e..c7a2ad739f 100644 --- a/crates/ui-egui/tests/it/trust_signer.rs +++ b/crates/ui-egui/tests/it/trust_signer.rs @@ -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) diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 12723274d5..cd5ce8dbb1 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -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); diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 28eb6fd768..a83b4cb756 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -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);