mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
Merge #4378: M3: documents that hide the toolbar, menu bar or window controls open that way, with a key to bring them back (organize.initial-view)
# Conflicts: # crates/engine/src/commands.rs # crates/ui-egui/src/commands.rs
This commit is contained in:
commit
6d1efb7646
15 files changed
+826
-12
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<SavedDisplay>,
|
||||
@@ -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);
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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<crate::viewer_prefs::PageNow> {
|
||||
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) }
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<DocId>,
|
||||
/// 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<viewer_prefs::PendingWindow>,
|
||||
/// An absolute GoToR target waiting for approval, before any target-file I/O (#1364).
|
||||
pub pending_remote_link: Option<PendingRemoteLink>,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Rect>,
|
||||
pub monitor: Option<Vec2>,
|
||||
/// 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<f32>,
|
||||
}
|
||||
|
||||
/// 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<ViewportCommand> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -92,4 +92,5 @@ mod ui;
|
||||
mod updates;
|
||||
mod view;
|
||||
mod viewer;
|
||||
mod viewer_prefs;
|
||||
mod xfa;
|
||||
@@ -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<u8> {
|
||||
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<egui::Vec2>, Option<egui::Pos2>) {
|
||||
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());
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user