diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9000b71b0e..1996bcd793 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -231,6 +231,16 @@ pub const COMMANDS: &[CommandSpec] = &[ // Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one; // a tab's right-click menu also splits right or down with any tab. c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"), + // A document can hide the toolbars, menu and panels (ISO 32000-2 Table 147); this brings them + // back for its tab, and with nothing hidden hides them. + c( + "view.toggle_interface", + "Show or hide toolbars and panels", + VIEW, + Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F8" }), + Document, + "panel-left", + ), // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and // also run from Combine files, which has no document open. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 0c72f09e4b..956a6238bf 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -305,6 +305,7 @@ impl PdfCraftApp { community_card_closed: false, pending_link: None, pending_full_screen: None, + pending_window: None, pending_remote_link: None, protect_draft: Default::default(), text_style_draft: Default::default(), @@ -779,7 +780,12 @@ impl PdfCraftApp { window_controls::resize_edges(&ctx); } chrome::tab_strip(self, ui); - chrome::mode_bar(self, ui); + // A document can hide the toolbars, the menu and the panels around it in its tab (ISO + // 32000-2 Table 147); the tab strip stays, and F8 brings the rest back (`viewer_prefs`). + let hidden = self.hidden_interface(); + if !hidden.mode_bar(self.native_menu.is_some()) { + chrome::mode_bar(self, ui); + } // Another mode or the Home tab puts the OCR Verify screen away, as Exit does; Correct // recognized text brings it back with its review as it was. if self.ocr_verify.open && (self.active.is_none() || self.ocr_verify.shown_in.is_some_and(|m| m != self.mode)) { @@ -806,15 +812,19 @@ impl PdfCraftApp { if self.all_tools_full && self.all_tools_tab != self.shown_tab() { self.all_tools_full = false; } - side_panels::show(self, ui); + if !hidden.window_ui { + side_panels::show(self, ui); + } let t = theme::Tokens::get(&ctx); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { - if self.mode == Mode::AllTools && self.all_tools_full { + if self.mode == Mode::AllTools && self.all_tools_full && !hidden.window_ui { all_tools_ui::show(self, ui); } else { panes::page_area(self, ui); } }); + // FitWindow and CenterWindow, once the document just opened is laid out. + self.window_preferences_frame(&ctx); self.process_pending_edits(); palette::show(self, &ctx); dialogs::show(self, &ctx); diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index ef3d31a256..a59f5b19c0 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -351,9 +351,11 @@ impl PdfCraftApp { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); - // Where the reader left off wins over the file's own initial view (#1767). + self.apply_viewer_preferences(self.views.len() - 1, &initial, activate); + // Where the reader left off wins over the file's own initial view and the zoom its + // viewer preferences open at (#1767). self.apply_last_view(self.views.len() - 1); - // After both, so full screen shows one fitted page and leaving it comes back to them. + // After all of them, so full screen shows one fitted page and leaving it comes back to them. if self.full_screen && let Some(view) = self.views.last_mut() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 983af1d993..171460fcc1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -561,6 +561,9 @@ pub struct DocView { pub flash: Option<(usize, [f32; 4], f64)>, /// Compare files: differences shaded on this document's pages (page, user-space box, colour). pub compare_marks: Vec<(usize, [f32; 4], Color32)>, + /// The interface the document hid in this tab, and what is hidden now (ISO 32000-2 Table 147 + /// `HideToolbar`, `HideMenubar`, `HideWindowUI`). Never saved. + pub interface: crate::viewer_prefs::TabInterface, /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. presentation: Option, @@ -1039,6 +1042,7 @@ impl DocView { single_edges: None, flash: None, compare_marks: Vec::new(), + interface: Default::default(), presentation: None, pages: HashMap::new(), errors: HashMap::new(), @@ -1214,6 +1218,8 @@ impl DocView { fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; + // What the document asked to hide, and what is hidden now (F8), belong to the tab. + fresh.interface = self.interface; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); fresh.back = std::mem::take(&mut self.back); @@ -2609,6 +2615,11 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry // Every scroll here asks for no animation. An animated area would still queue a zero-length // offset target for the next frame, which overwrites the jump a page turn just set. scroll = scroll.animated(false); + // A document that hides the window's interface (`HideWindowUI`) hides the scroll bars too; + // the wheel, keys and Hand tool still scroll. + if view.interface.hidden.window_ui { + scroll = scroll.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden); + } // A newly opened document starts at the top. eframe saves egui's memory with the settings // and document ids start again at 1 every launch, so the offset stored under this id can be // where an earlier session left another document. @@ -3721,7 +3732,8 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, } // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); // the quick tools come back with the window. - if app.mode != crate::Mode::Read && !app.full_screen { + // A document that hides the toolbars (`HideToolbar`) hides this one too. + if app.mode != crate::Mode::Read && !app.full_screen && !app.views.get(index).is_some_and(|v| v.interface.hidden.toolbar) { // The page nearest the left edge, whose margin the bar sits in. let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min); quick_bar(app, avail, page_left, ui); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 1e8add9b2d..b710261ba8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2695,6 +2695,35 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].tiles.clear(); } +/// Review on #4378: `FitWindow` measures the spread the view shows, its pages side by side +/// and as tall as the taller one, with one page in a spread that has only one. +#[test] +fn first_page_now_measures_the_pages_in_the_spread() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + let info = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(200.0, 500.0)], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + let one = v.first_page_now(&info).expect("a page"); + assert_eq!((one.size, one.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.set_display(PageLayout::TwoUp, false); + let gap = v.page_gap(); + // [0, 1]: 300 + 600 wide, 800 high, one gap between them. + let spread = v.first_page_now(&info).expect("a spread"); + assert_eq!((spread.size, spread.margins.x), (vec2(900.0 * PT, 800.0 * PT), 2.0 * SIDE + gap)); + // [2] on its own at the end: one page, no gap. + v.current = 2; + let last = v.first_page_now(&info).expect("the last page"); + assert_eq!((last.size, last.margins.x), (vec2(200.0 * PT, 500.0 * PT), 2.0 * SIDE)); + // With a cover page: [0] alone, then [1, 2]. + v.set_cover(true); + assert_eq!(v.first_page_now(&info).expect("a spread").size, vec2(800.0 * PT, 800.0 * PT)); + v.current = 0; + let cover = v.first_page_now(&info).expect("the cover"); + assert_eq!((cover.size, cover.margins.x), (vec2(300.0 * PT, 400.0 * PT), 2.0 * SIDE)); + v.current = 9; + assert!(v.first_page_now(&info).is_none()); +} + #[test] fn image_handles_account_for_document_and_view_rotation() { for page_rotation in [0, 90, 180, 270] { diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index ef1239110a..6c10fc4feb 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -140,6 +140,31 @@ impl DocView { self.layout == PageLayout::TwoUp } + /// The page (or spread) the view shows first, as `FitWindow` sizes the window to it + /// ([`crate::viewer_prefs::window_commands`]). `None` when there is no such page. + pub(crate) fn first_page_now(&self, info: &DocInfo) -> Option { + info.pages.get(self.current)?; + // Two-page view shows the spread the page is in (a cover page on its own); the spread + // is as wide as its pages side by side and as tall as the taller one, as `layout` + // places it. The last spread of an odd page count has one page. + let (first, last) = if self.layout == PageLayout::TwoUp { + let first = self.spread_first(self.current); + (first, if self.cover && first == 0 { 0 } else { first.saturating_add(1) }) + } else { + (self.current, self.current) + }; + let sizes: Vec<(f32, f32)> = + info.pages.iter().skip(first).take(last.saturating_sub(first).saturating_add(1)).map(|p| self.display_size(p)).collect(); + let w: f32 = sizes.iter().map(|s| s.0).sum(); + let h = sizes.iter().map(|s| s.1).fold(0.0, f32::max); + let gap = self.page_gap() * sizes.len().saturating_sub(1) as f32; + Some(crate::viewer_prefs::PageNow { + size: vec2(w * PT, h * PT), + margins: vec2(2.0 * SIDE + gap, 2.0 * MARGIN), + zoom: (self.fit == Fit::None).then_some(self.zoom), + }) + } + /// Displayed page size in points for this view rotation. pub(super) fn display_size(&self, p: &pdfcraft_render::PageInfo) -> (f32, f32) { if self.rotation % 180 == 90 { (p.height, p.width) } else { (p.width, p.height) } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dba8419919..4cc264e7dc 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -588,19 +588,24 @@ const MODE_BAR_SEARCH_MIN: f32 = 140.0; pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); + // The document in the active tab may hide the menu or the tools (`crate::viewer_prefs`). + let hidden = app.hidden_interface(); egui::Panel::top("mode_bar") .exact_size(48.0) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.divider))) .show(ui, |ui| { ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 2.0; - // The menus, unless the native macOS menu bar has them. - if app.native_menu.is_none() { + // The menus, unless the native macOS menu bar has them or the document hides them. + if app.native_menu.is_none() && !hidden.menubar { 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); } + if hidden.toolbar { + return; + } // 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. let modes = [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]; diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 2d67ecd2b2..ac52586062 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -334,6 +334,7 @@ impl PdfCraftApp { } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), "view.split" => self.toggle_split(), + crate::viewer_prefs::TOGGLE => self.toggle_interface(), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { return Some(v.view_history(id == "view.next_view")); @@ -832,6 +833,11 @@ impl PdfCraftApp { if matches!(spec.id, "view.previous_view" | "view.next_view") && !self.command_enabled(spec) { continue; } + // The OCR Verify screen keeps the window's toolbars and owns F8 and Shift+F8 (the next + // and previous word to review), so they don't show or hide the interface there. + if spec.id == crate::viewer_prefs::TOGGLE && self.ocr_verify.open { + continue; + } // A box typed into on the page (a paragraph being edited, added text) keeps Undo and // Redo only while it holds typing the page doesn't have, or Redo has typing to put // back; then they reach the document. ⌘⇧Z is also ⌘Z to the Undo shortcut, so a diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index cf8e5e7fcc..1373313a9c 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -152,6 +152,7 @@ impl Host for crate::PdfCraftApp { "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, "right_panel": self.right.map(|r| format!("{r:?}")), + "hidden_interface": self.hidden_interface().to_json(), "dialog": self.dialog.map(|d| format!("{d:?}")), "palette_open": self.palette_open, "theme": format!("{:?}", self.theme), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c96765fda6..1da3a5fd18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -63,6 +63,7 @@ pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, Printer pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; pub mod theme; +pub mod viewer_prefs; mod widgets; use pdfcraft_engine::{DocId, Session}; @@ -791,6 +792,9 @@ pub struct PdfCraftApp { /// A document just opened asks to be shown in Full Screen mode (`/PageMode /FullScreen`): /// the person is asked first, as in Acrobat, and this is the document waiting for the answer. pub pending_full_screen: Option, + /// A document just opened asks for its window to fit its first page or to be centred + /// (`FitWindow`, `CenterWindow`): done once the page is laid out. + pub pending_window: Option, /// An absolute GoToR target waiting for approval, before any target-file I/O (#1364). pub pending_remote_link: Option, } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6987ba3b36..066a914e0d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -173,7 +173,7 @@ const DISPLAY: &[&str] = &[ const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; const PANES: &[&str] = &["view.side_panel", "view.tools_pane", "view.split"]; const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; -const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; +const MODES: &[&str] = &["view.full_screen", "view.read_mode", crate::viewer_prefs::TOGGLE]; const PAGES: &[&str] = &[ "page.organize", SEP, diff --git a/crates/ui-egui/src/viewer_prefs.rs b/crates/ui-egui/src/viewer_prefs.rs new file mode 100644 index 0000000000..8b6f023b6d --- /dev/null +++ b/crates/ui-egui/src/viewer_prefs.rs @@ -0,0 +1,374 @@ +//! Viewer preferences that change the window and its interface when a document opens +//! (ISO 32000-2 §12.2, Table 147): `HideToolbar`, `HideMenubar` and `HideWindowUI` hide parts of +//! the interface for that document's tab, and `FitWindow` and `CenterWindow` size and place the +//! window around its first page. +//! +//! The hidden interface is the tab's own and is never saved: another tab shows the usual +//! interface, and the command [`TOGGLE`] (F8) brings it back, so a document can't leave the user +//! without the toolbar, menu or panels. + +use egui::{Pos2, Rect, Vec2, ViewportCommand, vec2}; + +/// The command that shows the interface a document hid, and hides it again. +pub const TOGGLE: &str = "view.toggle_interface"; + +/// Which parts of the interface are hidden in a document's tab. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HiddenInterface { + /// The mode bar's tools: the mode tabs, Save, Print, Properties and the command search, and + /// the floating quick-action bar (`HideToolbar`). + pub toolbar: bool, + /// The menu bar (`HideMenubar`). + pub menubar: bool, + /// The right rail with its page navigation, the side panels and the document's scroll bars, + /// "leaving only the document's contents displayed" (`HideWindowUI`). + pub window_ui: bool, +} + +impl HiddenInterface { + pub const NONE: Self = Self { toolbar: false, menubar: false, window_ui: false }; + pub const ALL: Self = Self { toolbar: true, menubar: true, window_ui: true }; + + /// What a document's viewer preferences ask to hide. + pub fn asked_by(v: &pdfcraft_engine::InitialView) -> Self { + Self { toolbar: v.hide_toolbar, menubar: v.hide_menubar, window_ui: v.hide_window_ui } + } + + pub fn any(self) -> bool { + self.toolbar || self.menubar || self.window_ui + } + + /// The mode bar has nothing left to show: the tools are hidden, and so are the menus, by the + /// document or because the native macOS menu bar (`native_menu`) has them instead. + pub fn mode_bar(self, native_menu: bool) -> bool { + self.toolbar && (self.menubar || native_menu) + } + + /// For the control channel's `ui.state`. + pub fn to_json(self) -> serde_json::Value { + serde_json::json!({ "toolbar": self.toolbar, "menubar": self.menubar, "window_ui": self.window_ui }) + } +} + +/// A tab's interface: what its document asked to hide, and what is hidden now. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TabInterface { + pub asked: HiddenInterface, + pub hidden: HiddenInterface, +} + +impl TabInterface { + /// The interface a document opens with: what it asks to hide, unless the user's preference + /// is to open every document their own way. + pub fn opening(asked: HiddenInterface, ignore_document: bool) -> Self { + if ignore_document { Self::default() } else { Self { asked, hidden: asked } } + } + + /// [`TOGGLE`]: show everything that is hidden; with nothing hidden, hide what the document + /// asked to hide, or everything when it asked for nothing. Returns whether it is now hidden. + pub fn toggle(&mut self) -> bool { + self.hidden = if self.hidden.any() { + HiddenInterface::NONE + } else if self.asked.any() { + self.asked + } else { + HiddenInterface::ALL + }; + self.hidden.any() + } +} + +/// `FitWindow` and `CenterWindow`, waiting for the document's first frame (the size of its page +/// on screen is known only once it is laid out). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingWindow { + pub doc: pdfcraft_engine::DocId, + pub fit: bool, + pub center: bool, + /// Frames left to wait for the document to be laid out before giving up. + pub frames_left: u8, +} + +/// What the window looks like now, as the windowing layer reports it (points). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WindowNow { + /// The window's content area. + pub inner: Vec2, + /// The window with its frame, where the windowing layer knows it. + pub outer: Option, + pub monitor: Option, + /// The document area inside the window. + pub document_area: Vec2, + pub maximized: bool, + pub fullscreen: bool, +} + +/// The first displayed page (or spread) as the view lays it out. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PageNow { + /// Its width and height on screen at 100%, in points. + pub size: Vec2, + /// The space the view keeps around it (gutters, margins, the gap in a spread), in points. + pub margins: Vec2, + /// The view's set zoom (1.0 = 100%); `None` when the view fits the page to the window. + pub zoom: Option, +} + +/// Smallest window the fit makes, so a tiny page doesn't leave a sliver of a window. +const MIN_INNER: Vec2 = vec2(480.0, 360.0); +/// Share of the monitor the fitted window may take, leaving room for the desktop's own bars. +const MONITOR_SHARE: f32 = 0.9; + +/// The viewport commands for `FitWindow` / `CenterWindow`. None for a maximized or full-screen +/// window (resizing it would fight the user's choice), or when the sizes are unusable. +/// +/// `FitWindow` keeps the document area's height and makes the window as wide as the page needs +/// at that height when the view fits the page to the window, and makes the document area the +/// page's size on screen when the view has a set zoom; either way within the monitor. +/// `CenterWindow` centres the window on the monitor when the window is on the monitor at the +/// desktop's origin (the windowing layer reports the monitor's size but not where it is). +pub fn window_commands(now: WindowNow, page: PageNow, fit: bool, center: bool) -> Vec { + let mut out = Vec::new(); + if now.maximized || now.fullscreen || !usable(now.inner) || !usable(now.document_area) { + return out; + } + let chrome = (now.inner - now.document_area).max(Vec2::ZERO); + let mut inner = now.inner; + let zoom_ok = page.zoom.is_none_or(|z| z.is_finite() && z > 0.0); + if fit && zoom_ok && usable(page.size) && page.margins.x.is_finite() && page.margins.y.is_finite() { + let area = match page.zoom { + // Fitted to the window: keep the document area's height, and make the area as wide + // as the page is at the zoom that height gives it. + None => { + let zoom = ((now.document_area.y - page.margins.y) / page.size.y).max(0.0); + vec2(page.size.x * zoom + page.margins.x, now.document_area.y) + } + Some(zoom) => page.size * zoom + page.margins, + }; + let mut target = area + chrome; + if let Some(monitor) = now.monitor.filter(|m| usable(*m)) { + target = target.min(monitor * MONITOR_SHARE); + } + target = target.max(MIN_INNER).round(); + if usable(target) && (target - now.inner).length() >= 1.0 { + out.push(ViewportCommand::InnerSize(target)); + inner = target; + } + } + if center + && let (Some(outer), Some(monitor)) = (now.outer, now.monitor.filter(|m| usable(*m))) + && Rect::from_min_size(Pos2::ZERO, monitor).contains(outer.min) + { + // The frame around the content area stays the size it is. + let frame = (outer.size() - now.inner).max(Vec2::ZERO); + let size = inner + frame; + let at = ((monitor - size) / 2.0).max(Vec2::ZERO).round(); + if at.x.is_finite() && at.y.is_finite() && (at - outer.min.to_vec2()).length() >= 1.0 { + out.push(ViewportCommand::OuterPosition(at.to_pos2())); + } + } + out +} + +fn usable(v: Vec2) -> bool { + v.x.is_finite() && v.y.is_finite() && v.x >= 1.0 && v.y >= 1.0 +} + +impl crate::PdfCraftApp { + /// The window and interface preferences of the document opening in tab `index` + /// ([`Self::apply_initial_view`] has applied its navigation, layout, zoom and page). + /// + /// - `HideToolbar`, `HideMenubar`, `HideWindowUI` hide those parts of the interface in its tab, + /// unless Always use my default zoom and page layout is on, with a notice of the key that + /// brings them back. + /// - A document that shows one page or one spread at a time (`/PageLayout /SinglePage`, + /// `/TwoPageLeft`, `/TwoPageRight`) and names no zoom opens at fit page instead of the + /// default zoom. ISO 32000-2 doesn't say which zoom such a layout opens at. + /// - `FitWindow`, `CenterWindow` wait for the page to be laid out + /// ([`Self::window_preferences_frame`]), and only for the window's only document: a + /// document opened in a tab beside others doesn't resize or move their window. + pub(crate) fn apply_viewer_preferences(&mut self, index: usize, v: &pdfcraft_engine::InitialView, activate: bool) { + use pdfcraft_engine::{InitialLayout as L, Magnification as M}; + let own_view = self.always_default_view; + let only_document = self.views.len() == 1 && !self.combine_tab.open; + let Some(view) = self.views.get_mut(index) else { return }; + view.interface = TabInterface::opening(HiddenInterface::asked_by(v), own_view); + if !own_view && v.magnification == M::Default && matches!(v.layout, L::SinglePage | L::TwoUp | L::TwoUpCoverPage) { + view.fit = crate::canvas::Fit::Page; + } + let (doc, hidden) = (view.id, view.interface.hidden.any()); + if activate && hidden { + self.notify_interface_hidden(); + } + // A browser page can't resize or move its window. + if !cfg!(target_arch = "wasm32") && activate && only_document && !self.full_screen && (v.fit_window || v.center_window) { + self.pending_window = Some(PendingWindow { doc, fit: v.fit_window, center: v.center_window, frames_left: 30 }); + } + } + + /// The parts of the interface hidden in the active tab. + pub fn hidden_interface(&self) -> HiddenInterface { + self.active.and_then(|i| self.views.get(i)).map_or(HiddenInterface::NONE, |v| v.interface.hidden) + } + + /// [`TOGGLE`] in the active tab. + pub(crate) fn toggle_interface(&mut self) { + let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return }; + if view.interface.toggle() { + self.notify_interface_hidden(); + } + } + + fn notify_interface_hidden(&mut self) { + let key = pdfcraft_engine::commands::command(TOGGLE).and_then(|c| c.shortcut).map(|s| s.label(cfg!(target_os = "macos"))).unwrap_or_default(); + self.notify_fmt("This document hides the toolbars and panels. Press {key} to show them.", &[("key", &key)]); + } + + /// `FitWindow` and `CenterWindow` for the document just opened, once its first page is laid + /// out. Dropped when another tab shows first, when another tab has opened behind it (it is no + /// longer the window's only document), or after a few frames without a layout. + pub(crate) fn window_preferences_frame(&mut self, ctx: &egui::Context) { + let Some(mut pending) = self.pending_window.take() else { return }; + let Some((index, id)) = self.active_ids() else { return }; + if id != pending.doc || self.views.len() != 1 || self.combine_tab.open { + return; + } + let Some(view) = self.views.get(index) else { return }; + let area = view.viewport_rect(); + let page = self.session.get(id).and_then(|d| view.first_page_now(&d.info)); + let Some(page) = page.filter(|_| area.is_positive()) else { + if let Some(left) = pending.frames_left.checked_sub(1) { + pending.frames_left = left; + self.pending_window = Some(pending); + ctx.request_repaint(); + } + return; + }; + let content = ctx.content_rect().size(); + let now = ctx.input(|i| { + let vp = i.viewport(); + WindowNow { + inner: vp.inner_rect.map_or(content, |r| r.size()), + outer: vp.outer_rect, + monitor: vp.monitor_size, + document_area: area.size(), + maximized: vp.maximized.unwrap_or(false), + fullscreen: vp.fullscreen.unwrap_or(false), + } + }); + for command in window_commands(now, page, pending.fit, pending.center) { + ctx.send_viewport_cmd(command); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn now() -> WindowNow { + WindowNow { + inner: vec2(1400.0, 900.0), + outer: Some(Rect::from_min_size(Pos2::new(50.0, 40.0), vec2(1400.0, 930.0))), + monitor: Some(vec2(1920.0, 1080.0)), + document_area: vec2(1300.0, 800.0), + maximized: false, + fullscreen: false, + } + } + + const FITTED: PageNow = PageNow { size: vec2(200.0, 300.0), margins: vec2(140.0, 56.0), zoom: None }; + + #[test] + fn a_fitted_page_gets_a_window_of_its_shape() { + // An 800-point-high area fits the 300-point page at 248%: 496 points wide plus margins, + // and the 100 points of interface beside the document area. + let c = window_commands(now(), FITTED, true, false); + assert_eq!(c, vec![ViewportCommand::InnerSize(vec2(736.0, 900.0))]); + } + + #[test] + fn a_set_zoom_gets_the_page_size_within_the_monitor() { + let page = PageNow { size: vec2(400.0, 500.0), margins: vec2(100.0, 100.0), zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(600.0, 700.0))]); + // Too tall for the monitor: the window stops at the share of the monitor it may take. + let page = PageNow { size: vec2(1000.0, 2000.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(vec2(1100.0, 972.0))]); + // A tiny page doesn't make a sliver of a window. + let page = PageNow { size: vec2(10.0, 10.0), margins: Vec2::ZERO, zoom: Some(1.0) }; + assert_eq!(window_commands(now(), page, true, false), vec![ViewportCommand::InnerSize(MIN_INNER)]); + } + + #[test] + fn a_maximized_or_full_screen_window_is_left_alone() { + assert!(window_commands(WindowNow { maximized: true, ..now() }, FITTED, true, true).is_empty()); + assert!(window_commands(WindowNow { fullscreen: true, ..now() }, FITTED, true, true).is_empty()); + // Nothing asked, nothing done. + assert!(window_commands(now(), FITTED, false, false).is_empty()); + } + + #[test] + fn centring_uses_the_new_size_and_the_frame() { + let c = window_commands(now(), FITTED, true, true); + // 736 wide and 900 + 30 of frame high, on 1920 × 1080. + assert_eq!(c.get(1), Some(&ViewportCommand::OuterPosition(Pos2::new(592.0, 75.0)))); + // Centre alone keeps the size. + let c = window_commands(now(), FITTED, false, true); + assert_eq!(c, vec![ViewportCommand::OuterPosition(Pos2::new(260.0, 75.0))]); + // A window on another monitor (outside the one at the origin) isn't moved. + let away = WindowNow { outer: Some(Rect::from_min_size(Pos2::new(2000.0, 40.0), vec2(1400.0, 930.0))), ..now() }; + assert!(window_commands(away, FITTED, false, true).is_empty()); + // Nor is one whose monitor or frame is unknown. + assert!(window_commands(WindowNow { monitor: None, ..now() }, FITTED, false, true).is_empty()); + assert!(window_commands(WindowNow { outer: None, ..now() }, FITTED, false, true).is_empty()); + } + + #[test] + fn unusable_sizes_do_nothing() { + for bad in [Vec2::ZERO, vec2(f32::NAN, 10.0), vec2(f32::INFINITY, 10.0), vec2(-5.0, 10.0)] { + let page = PageNow { size: bad, ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { inner: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { document_area: bad, ..now() }, FITTED, true, true).is_empty(), "{bad:?}"); + assert!(window_commands(WindowNow { monitor: Some(bad), ..now() }, FITTED, false, true).is_empty(), "{bad:?}"); + } + for zoom in [f32::NAN, 0.0, -1.0, f32::INFINITY] { + assert!(window_commands(now(), PageNow { zoom: Some(zoom), ..FITTED }, true, false).is_empty(), "{zoom}"); + } + let page = PageNow { margins: vec2(f32::NAN, 0.0), ..FITTED }; + assert!(window_commands(now(), page, true, false).is_empty()); + } + + #[test] + fn toggling_restores_and_rehides() { + let asked = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + let mut tab = TabInterface::opening(asked, false); + assert_eq!(tab.hidden, asked); + assert!(!tab.toggle()); + assert_eq!(tab.hidden, HiddenInterface::NONE); + assert!(tab.toggle()); + assert_eq!(tab.hidden, asked); + // The user's preference to open documents their own way hides nothing. + let mut own = TabInterface::opening(asked, true); + assert_eq!(own.hidden, HiddenInterface::NONE); + // A tab whose document asked for nothing hides everything, and shows it again. + assert!(own.toggle()); + assert_eq!(own.hidden, HiddenInterface::ALL); + assert!(!own.toggle()); + } + + #[test] + fn the_mode_bar_goes_when_nothing_is_left_on_it() { + let toolbar = HiddenInterface { toolbar: true, ..HiddenInterface::NONE }; + // The in-window menus are still there. + assert!(!toolbar.mode_bar(false)); + // #4378 review: on macOS the native menu bar has the menus, so hiding the tools leaves an + // empty bar, which goes too. + assert!(toolbar.mode_bar(true)); + let both = HiddenInterface { menubar: true, ..toolbar }; + assert!(both.mode_bar(false) && both.mode_bar(true)); + // Menus hidden, tools shown: the bar has the tools. + let menubar = HiddenInterface { menubar: true, ..HiddenInterface::NONE }; + assert!(!menubar.mode_bar(false) && !menubar.mode_bar(true)); + } +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 11b93089c4..f70f808d8a 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -92,4 +92,5 @@ mod ui; mod updates; mod view; mod viewer; +mod viewer_prefs; mod xfa; diff --git a/crates/ui-egui/tests/it/viewer_prefs.rs b/crates/ui-egui/tests/it/viewer_prefs.rs new file mode 100644 index 0000000000..781e461668 --- /dev/null +++ b/crates/ui-egui/tests/it/viewer_prefs.rs @@ -0,0 +1,335 @@ +//! Viewer preferences applied when a document opens (ISO 32000-2 §12.2, Table 147): hiding the +//! toolbars, menu and window controls in its tab, with F8 to bring them back; fitting and +//! centring the window; and the zoom a one-page-at-a-time layout opens at. + +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::canvas::Fit; +use pdfcraft_ui_egui::viewer_prefs::HiddenInterface; + +/// Two 200 × 300 pages; `catalog` is added to the catalog dictionary. +fn fixture(catalog: &str) -> Vec { + let objs = [ + format!("<< /Type /Catalog /Pages 2 0 R {catalog} >>"), + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 300] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +const HIDE_ALL: &str = "/ViewerPreferences << /HideToolbar true /HideMenubar true /HideWindowUI true >>"; + +/// A plain document in the first tab and `files` after it, the last one active. +fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static, files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + setup(&mut app); + for (i, catalog) in files.iter().enumerate() { + app.open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + app + }); + settle(&mut h); + h +} + +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..3000 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + h.run_steps(2); +} + +/// Which of the toolbar (the command search), the menu (the menu bar's File menu) and the window +/// controls (the rail's fit button) are on screen. +fn shown(h: &Harness<'static, PdfCraftApp>) -> (bool, bool, bool) { + ( + h.query_by_label("Find tools and commands").is_some(), + h.query_by(|n| n.role() == egui::accesskit::Role::MenuItem && n.label().as_deref() == Some("File")).is_some(), + h.query_by_label("Toggle fit page / fit width").is_some(), + ) +} + +#[test] +fn a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back() { + let mut h = harness(|_| {}, &["", HIDE_ALL]); + assert_eq!(shown(&h), (false, false, false), "the toolbar, menu and rail are hidden"); + assert_eq!(h.state().hidden_interface(), HiddenInterface::ALL); + // The quick-action bar is a toolbar too. + assert!(h.query_by_label_contains("Hand (H)").is_none()); + // The tab strip stays: the tabs and Home are still there. + h.get_by_label("Home"); + + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true), "F8 shows them"); + h.get_by_label_contains("Hand (H)"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false), "and hides them again"); + + // Another tab, whose document asks for nothing, has the usual interface. + h.state_mut().active = Some(0); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.state_mut().active = Some(1); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + + // The command (palette, menus, automation) does what F8 does. + assert!(h.state_mut().execute("view.toggle_interface")); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +/// Reloading a file changed on disk keeps the tab's interface: hidden stays hidden, and F8 still +/// hides only what the document asked for. +#[test] +fn a_reloaded_document_keeps_its_interface() { + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + let reload = |h: &mut Harness<'static, PdfCraftApp>| { + let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone(); + h.state_mut().views[0].document_replaced(&info); + h.run_steps(3); + }; + reload(&mut h); + assert_eq!(shown(&h), (false, true, true), "still hidden after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + reload(&mut h); + assert_eq!(shown(&h), (true, true, true), "still shown after the reload"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true), "F8 hides only what the document asked for"); +} + +#[test] +fn each_preference_hides_its_own_part() { + let top = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().top(); + let mut h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true >>"]); + assert_eq!(shown(&h), (false, true, true), "HideToolbar"); + // Shown and hidden again, F8 hides only what the document asked for. + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, true, true)); + let h = harness(|_| {}, &["/ViewerPreferences << /HideMenubar true >>"]); + assert_eq!(shown(&h), (true, false, true), "HideMenubar"); + let h = harness(|_| {}, &["/ViewerPreferences << /HideWindowUI true >>"]); + assert_eq!(shown(&h), (true, true, false), "HideWindowUI"); + assert!(h.state().hidden_interface().window_ui); + // False is the default, and is nothing to hide. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + assert_eq!(shown(&h), (true, true, true)); + let with_bar = top(&h); + // With both the tools and the menu hidden, the mode bar goes and the document moves up. + let h = harness(|_| {}, &["/ViewerPreferences << /HideToolbar true /HideMenubar true >>"]); + assert_eq!(shown(&h), (false, false, true)); + assert!(top(&h) < with_bar - 40.0, "{} against {with_bar}", top(&h)); +} + +#[test] +fn hiding_the_window_controls_leaves_the_document_the_whole_width() { + // The document also asks for the page thumbnails panel, which is hidden with the rest. + let mut h = harness(|_| {}, &["/PageMode /UseThumbs /ViewerPreferences << /HideWindowUI true >>"]); + let width = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].viewport_rect().width(); + assert!(width(&h) > 1390.0, "{}", width(&h)); + h.key_press(egui::Key::F8); + settle(&mut h); + // The rail and the Pages panel are back beside the document. + assert!(width(&h) < 1400.0 - 48.0 - 200.0, "{}", width(&h)); +} + +#[test] +fn the_preference_to_ignore_document_views_keeps_the_interface() { + let h = harness(|app| app.always_default_view = true, &[HIDE_ALL]); + assert_eq!(shown(&h), (true, true, true)); + assert_eq!(h.state().hidden_interface(), HiddenInterface::NONE); +} + +#[test] +fn f8_hides_the_interface_of_any_document_and_shows_it_again() { + let mut h = harness(|_| {}, &[""]); + assert_eq!(shown(&h), (true, true, true)); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (false, false, false)); + h.get_by_label_contains("Press F8 to show them"); + h.key_press(egui::Key::F8); + h.run_steps(3); + assert_eq!(shown(&h), (true, true, true)); +} + +#[test] +fn hiding_the_interface_is_never_saved() { + let h = harness(|_| {}, &[HIDE_ALL]); + let settings = h.state().persist(); + assert!(!settings.contains("interface"), "{settings}"); +} + +/// The fit a document with `catalog` opens at, with the user's defaults (fit width). +fn opening_fit(catalog: &str, own_view: bool) -> Fit { + let mut app = PdfCraftApp::new(); + app.always_default_view = own_view; + app.open_bytes("x.pdf", None, fixture(catalog)).unwrap(); + app.views[0].fit +} + +#[test] +fn one_page_at_a_time_opens_at_fit_page() { + let default = opening_fit("", false); + assert_eq!(default, Fit::Width, "the default zoom"); + for layout in ["SinglePage", "TwoPageLeft", "TwoPageRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), Fit::Page, "{layout}"); + // The user's preference keeps their zoom. + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), true), default, "{layout}"); + } + // Scrolling layouts keep the default zoom. + for layout in ["OneColumn", "TwoColumnLeft", "TwoColumnRight"] { + assert_eq!(opening_fit(&format!("/PageLayout /{layout}"), false), default, "{layout}"); + } + // A zoom the document names wins. + assert_eq!(opening_fit("/PageLayout /SinglePage /OpenAction [3 0 R /FitH 300]", false), Fit::Width); + let mut app = PdfCraftApp::new(); + app.open_bytes("x.pdf", None, fixture("/PageLayout /SinglePage /OpenAction [3 0 R /XYZ 0 300 2]")).unwrap(); + assert_eq!((app.views[0].fit, app.views[0].zoom), (Fit::None, 2.0)); +} + +/// Review on #4378: where the reader left off wins over the fit page a one-page-at-a-time +/// layout opens at. +#[test] +fn a_remembered_zoom_wins_over_the_layouts_fit_page() { + let path = std::env::temp_dir().join(format!("pdfcraft-viewer-prefs-last-view-{}.pdf", std::process::id())).to_string_lossy().into_owned(); + let mut app = PdfCraftApp::new(); + let json_path = serde_json::to_string(&path).unwrap(); + app.restore(&format!(r#"{{"last_views": [{{"path": {json_path}, "page": 1, "zoom": "62.5%", "display": "single"}}]}}"#)); + assert!(app.restore_last_view); + app.open_bytes("x.pdf", Some(path), fixture("/PageLayout /SinglePage")).unwrap(); + let v = &app.views[0]; + assert_eq!((v.fit, v.zoom, v.current), (Fit::None, 0.625, 1)); +} + +/// The last `InnerSize` and `OuterPosition` asked for over `frames` frames. +fn window_commands(h: &mut Harness<'static, PdfCraftApp>, frames: usize) -> (Option, Option) { + let (mut size, mut at) = (None, None); + for _ in 0..frames { + h.run_steps(1); + for c in &h.output().viewport_output[&egui::ViewportId::ROOT].commands { + match c { + egui::ViewportCommand::InnerSize(s) => size = Some(*s), + egui::ViewportCommand::OuterPosition(p) => at = Some(*p), + _ => {} + } + } + } + (size, at) +} + +fn on_monitor(h: &mut Harness<'static, PdfCraftApp>) { + let vp = h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default(); + vp.monitor_size = Some(egui::vec2(1920.0, 1080.0)); + vp.inner_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 70.0), egui::vec2(1400.0, 900.0))); + vp.outer_rect = Some(egui::Rect::from_min_size(egui::pos2(50.0, 40.0), egui::vec2(1400.0, 930.0))); +} + +/// A harness whose window is on a 1920 × 1080 monitor, with one document opened in the first +/// frames. +fn windowed(files: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + on_monitor(&mut h); + h.run_steps(2); + for (i, catalog) in files.iter().enumerate() { + h.state_mut().open_bytes(&format!("{i}.pdf"), None, fixture(catalog)).unwrap(); + } + h +} + +#[test] +fn fit_window_sizes_the_window_to_the_first_page() { + let mut h = windowed(&["/ViewerPreferences << /FitWindow true >> /OpenAction [3 0 R /Fit]"]); + let (size, at) = window_commands(&mut h, 12); + let size = size.expect("the window is resized"); + assert_eq!(at, None, "not moved"); + // A 2:3 portrait page fitted to the window's height makes a narrower window. + assert!(size.x < 1000.0 && (size.y - 900.0).abs() < 1.0, "{size:?}"); + // Asked once. + assert_eq!(window_commands(&mut h, 6), (None, None)); + // The page now fills the document area's height and nearly its width. + let v = &h.state().views[0]; + let (page, area) = (v.page_screen_rect(0).expect("shown"), v.viewport_rect()); + assert!(area.width() - page.width() < 160.0, "page {page:?} in {area:?}"); +} + +#[test] +fn center_window_centres_the_window_on_the_monitor() { + let mut h = windowed(&["/ViewerPreferences << /CenterWindow true >>"]); + // 1400 × 930 on 1920 × 1080. + assert_eq!(window_commands(&mut h, 12), (None, Some(egui::pos2(260.0, 75.0)))); + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + let (size, at) = window_commands(&mut h, 12); + let (size, at) = (size.expect("resized"), at.expect("moved")); + assert!(((1920.0 - size.x) / 2.0 - at.x).abs() <= 0.5, "{size:?} at {at:?}"); +} + +#[test] +fn a_maximized_window_or_a_second_tab_is_left_alone() { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| PdfCraftApp::new()); + on_monitor(&mut h); + h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().maximized = Some(true); + h.run_steps(2); + h.state_mut().open_bytes("0.pdf", None, fixture("/ViewerPreferences << /FitWindow true /CenterWindow true >>")).unwrap(); + assert_eq!(window_commands(&mut h, 12), (None, None), "maximized"); + + let mut h = windowed(&["", "/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + assert_eq!(window_commands(&mut h, 12), (None, None), "beside another document"); + + // A tab opened behind the document before its page is laid out (a late startup file) leaves + // the window alone too, though the document stays active. + let mut h = windowed(&["/ViewerPreferences << /FitWindow true /CenterWindow true >>"]); + h.state_mut().open_bytes("1.pdf", None, fixture("")).unwrap(); + h.state_mut().active = Some(0); + assert_eq!(window_commands(&mut h, 12), (None, None), "a tab opened behind it"); +} + +#[test] +fn the_user_is_told_which_key_brings_the_interface_back() { + let mut h = windowed(&[HIDE_ALL]); + h.run_steps(3); + h.get_by_label_contains("This document hides the toolbars and panels. Press F8 to show them."); + // Not when nothing is hidden. + let mut h = windowed(&["/ViewerPreferences << /HideToolbar false /HideMenubar false /HideWindowUI false >>"]); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); + // Nor when the user opens documents their own way. + let mut h = windowed(&[]); + h.state_mut().always_default_view = true; + h.state_mut().open_bytes("0.pdf", None, fixture(HIDE_ALL)).unwrap(); + h.run_steps(3); + assert!(h.query_by_label_contains("Press F8").is_none()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index ebd7519ff8..072eac9eea 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -3584,10 +3584,10 @@ area = "D" tier = "P1" milestone = "M4" status = "shipped" -commands = ["file.properties"] +commands = ["file.properties", "view.toggle_interface"] tools = ["doc_initial_view"] -evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/ui-egui/tests/it/viewer.rs::initial_view_magnification_offers_25_and_100_percent", "crates/organize/src/tests.rs::choosing_100_percent_keeps_an_actual_size_opening_destination", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds"] -notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen" +evidence = ["crates/organize/src/view.rs::initial_view_resolves_indirect_boolean_preferences", "crates/ui-egui/tests/it/ui.rs::a_document_asking_for_full_screen_is_asked_about_first", "crates/organize/src/tests.rs::initial_view_round_trips_and_keeps_scripts", "crates/ui-egui/tests/it/viewer.rs::initial_view_is_edited_and_honoured_on_open", "crates/ui-egui/tests/it/viewer.rs::initial_view_magnification_offers_25_and_100_percent", "crates/organize/src/tests.rs::choosing_100_percent_keeps_an_actual_size_opening_destination", "crates/automation/tests/it/automation.rs::initial_view_through_tools", "crates/render/src/inspect/navigation.rs::a_go_to_action_opens_at_its_destination_explicit_or_named", "crates/ui-egui/tests/it/open_action.rs::an_xyz_open_action_puts_its_point_at_the_window_top_left", "crates/ui-egui/tests/it/open_action.rs::a_fith_open_action_puts_its_top_at_the_window_top", "crates/ui-egui/tests/it/open_action.rs::a_named_open_destination_opens_its_page", "crates/ui-egui/tests/it/open_action.rs::a_full_screen_document_shows_its_non_full_screen_panel_outside_full_screen", "crates/organize/src/tests.rs::an_unchanged_opening_destination_keeps_its_position_and_name", "crates/automation/tests/it/automation.rs::doc_info_reports_the_open_destination", "crates/organize/src/tests.rs::choosing_page_one_replaces_a_named_opening_destination", "crates/organize/src/tests.rs::a_named_opening_destination_is_kept_while_it_still_holds", "crates/ui-egui/tests/it/viewer_prefs.rs::a_document_that_hides_the_interface_opens_without_it_and_f8_brings_it_back", "crates/ui-egui/tests/it/viewer_prefs.rs::one_page_at_a_time_opens_at_fit_page", "crates/ui-egui/tests/it/viewer_prefs.rs::fit_window_sizes_the_window_to_the_first_page"] +notes = "navigation panel, layout, magnification, page, window and interface options; honoured on open; script open actions kept. Open in Full Screen mode (/PageMode /FullScreen) asks first (Full Screen / Not now), as Acrobat does, instead of taking the screen. Window and interface options also read indirect boolean ViewerPreferences values. Missing: Acrobat's Full Screen preference to turn that alert off and its \"remember my choice for this document\" (misc.prefs-full-screen). On open the /OpenAction destination places the view as ISO 32000-2 Table 149 says (the /XYZ point at the window's top-left, /FitH and /FitV top or left at the edge), and a named destination (name or string, /Names /Dests tree or catalog /Dests, §12.3.2.4) resolves; Always use my default zoom keeps only the page. A full-screen document shows its /NonFullScreenPageMode panel outside full screen (Table 147). doc_info reports the opening destination. Document Properties keeps an opening action whose page and magnification are left alone, but still edits only those two; it shows a named destination's page and magnification, and choosing another page or magnification replaces the name with an explicit destination, except that page 1 at the default magnification removes /OpenAction so the first page opens. A name that resolves nowhere reads as page 1 and is kept until another page is chosen. HideToolbar, HideMenubar and HideWindowUI are applied in the document's tab only (the mode bar's tools and quick-action bar, the menu bar, the rail, side panels and scroll bars; the tab strip stays), never saved, skipped with Always use my default zoom and page layout, and F8 (view.toggle_interface) brings them back. FitWindow and CenterWindow apply to the window's only document when the window is neither maximized nor full screen; centring needs the window on the monitor at the desktop origin, and Wayland and the browser ignore both. SinglePage, TwoPageLeft and TwoPageRight without a named zoom open at fit page" [[feature]] id = "organize.custom-properties"