mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-02 14:38:13 -06:00
Merge pull request #1433 from srt8hellcat2-ui/feature/two-paged-cli-1354
M3: add optional --two-paged CLI flag (fixes #1354)
This commit is contained in:
@@ -140,6 +140,8 @@ Search the whole document as you type, step through matches with <kbd>⌘G</kbd>
|
||||
|
||||
## Navigate long documents
|
||||
|
||||
Launch with `pdfcraft --two-paged file.pdf` (or `pdfcraft file.pdf --two-paged`) to open each command-line PDF in noncontinuous two-page view. This optional launch override does not change saved default-layout preferences or PDFs restored from the previous session. An explicit `--layout` option takes precedence on the active document regardless of argument order; `--default-layout` retains its existing behavior for subsequently opened documents.
|
||||
|
||||
Right-click a PDF tab to close it, reopen the last closed file-backed tab, or reveal its file. Closing still asks about unsaved changes. Reopening reads the saved file from disk; it does not restore discarded edits. Reveal selects the file in Explorer or Finder. On Linux and FreeBSD it requests `org.freedesktop.FileManager1.ShowItems`, falling back to opening the containing directory with `xdg-open` when that service is unavailable. Reopen and Reveal are unavailable on the web; Reveal is also unavailable for pathless documents.
|
||||
|
||||
Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state.
|
||||
|
||||
@@ -213,6 +213,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
|
||||
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
|
||||
|
||||
- **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged.
|
||||
|
||||
+20
-28
@@ -1,6 +1,8 @@
|
||||
//! PdfCraft desktop app.
|
||||
//!
|
||||
//! Usage: `pdfcraft [options] [files…]` (`-h` / `--help` prints a short version of this, [`USAGE`]).
|
||||
//! `--two-paged` opens each command-line PDF in noncontinuous two-page view, without changing defaults.
|
||||
//! An explicit `--layout` takes precedence for the active document, regardless of argument order.
|
||||
//! `--create-images [images…]` stages the images in one PDF and asks for the page DPI.
|
||||
//!
|
||||
//! View options (applied after the files open; also the seed of the UI control channel):
|
||||
@@ -40,6 +42,7 @@ mod motion;
|
||||
mod reopen;
|
||||
mod settings;
|
||||
mod single_instance;
|
||||
mod startup;
|
||||
mod text_scale;
|
||||
#[cfg(test)]
|
||||
#[path = "windows_manifest.rs"]
|
||||
@@ -63,6 +66,7 @@ Usage: pdfcraft [options] [files…]
|
||||
Opens the files in the PdfCraft desktop app.
|
||||
|
||||
Options:
|
||||
--two-paged open command-line PDFs in noncontinuous two-page view
|
||||
--new-window open a window of its own instead of passing the files to a running PdfCraft
|
||||
--create-images [images…] put the images in one PDF and ask for the page DPI
|
||||
--control <file> enable the UI control channel (pdfcraft-cli ui --control <file> …)
|
||||
@@ -218,28 +222,16 @@ fn main() -> eframe::Result {
|
||||
let _ = std::io::Write::write_fmt(&mut std::io::stderr(), format_args!("pdfcraft: {report}\n"));
|
||||
}
|
||||
}));
|
||||
let mut files = Vec::new();
|
||||
let mut options: Vec<(String, String)> = Vec::new();
|
||||
let mut control_file: Option<String> = None;
|
||||
let mut create_images = false;
|
||||
let mut new_window = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--version" => return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version())),
|
||||
// Before any window or log file, so a scripted call returns (#4076).
|
||||
"-h" | "--help" => return print_and_exit(USAGE),
|
||||
"--control" => control_file = args.next(),
|
||||
"--create-images" => create_images = true,
|
||||
"--new-window" => new_window = true,
|
||||
flag if flag.starts_with("--") => {
|
||||
let value = args.next().unwrap_or_default();
|
||||
options.push((flag.trim_start_matches("--").to_string(), value));
|
||||
}
|
||||
_ => files.push(path_from_arg(a)),
|
||||
}
|
||||
let mut startup = startup::Arguments::parse(std::env::args().skip(1));
|
||||
if startup.version {
|
||||
return print_and_exit(&format!("pdfcraft {}", pdfcraft_engine::build_info::version()));
|
||||
}
|
||||
let files = rejoin_split_paths(files, |p| std::path::Path::new(p).is_file());
|
||||
if startup.help {
|
||||
return print_and_exit(USAGE);
|
||||
}
|
||||
startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file());
|
||||
let may_hand_off = startup.may_hand_off();
|
||||
let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup;
|
||||
let integrated = cfg!(target_os = "macos");
|
||||
// GNOME on Wayland leaves the window frame to the app (src/decorations.rs).
|
||||
let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() };
|
||||
@@ -260,7 +252,6 @@ fn main() -> eframe::Result {
|
||||
// Explorer double-click, a file manager or `xdg-open`) hands them to the PdfCraft already
|
||||
// running and exits (#282, #317, #1726). Any option means the caller wants this launch's own
|
||||
// window.
|
||||
let may_hand_off = !new_window && !files.is_empty() && options.is_empty() && control_file.is_none() && !create_images;
|
||||
// Absolute for this instance too: links relative to a document resolve against its folder,
|
||||
// never the working directory.
|
||||
let files = single_instance::absolute(&files);
|
||||
@@ -270,7 +261,7 @@ fn main() -> eframe::Result {
|
||||
single_instance::Claim::Alone => None,
|
||||
};
|
||||
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
|
||||
let launch = Launch { files, options, control_file, create_images, integrated, window_controls };
|
||||
let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged };
|
||||
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
|
||||
// that launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -380,6 +371,7 @@ struct Launch {
|
||||
integrated: bool,
|
||||
/// The window buttons the app draws, when the desktop leaves the frame to it.
|
||||
window_controls: Option<pdfcraft_ui_egui::window_controls::Layout>,
|
||||
two_paged: bool,
|
||||
}
|
||||
|
||||
/// Whether the window is a native Wayland one (not X11 or XWayland).
|
||||
@@ -400,7 +392,7 @@ fn app_creator<'a>(
|
||||
instance: Option<&'a single_instance::Server>,
|
||||
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
|
||||
) -> eframe::AppCreator<'a> {
|
||||
let Launch { files, options, control_file, create_images, integrated, window_controls } = launch;
|
||||
let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch;
|
||||
Box::new(move |cc| {
|
||||
started.set(true);
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -467,6 +459,7 @@ fn app_creator<'a>(
|
||||
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
|
||||
);
|
||||
}
|
||||
let defer_layout = two_paged && !files.is_empty() && !create_images;
|
||||
if create_images {
|
||||
if let Err(e) = app.begin_image_import_paths(&files) {
|
||||
app.notify(e);
|
||||
@@ -475,12 +468,11 @@ fn app_creator<'a>(
|
||||
// With the preference on, last session's files come back first; files named on
|
||||
// the command line open after them, in front (#442).
|
||||
app.reopen_last_files(&files);
|
||||
for f in files {
|
||||
app.open_path(&f);
|
||||
}
|
||||
startup::open_files(&mut app, &files, two_paged);
|
||||
}
|
||||
for (k, v) in options {
|
||||
if let Err(e) = app.set_option(&k, &v) {
|
||||
let result = if defer_layout && k == "layout" { app.set_launch_layout(&v) } else { app.set_option(&k, &v) };
|
||||
if let Err(e) = result {
|
||||
log::warn!("--{k} {v}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Native launch arguments and per-file view overrides (#1354).
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Arguments {
|
||||
pub files: Vec<String>,
|
||||
pub options: Vec<(String, String)>,
|
||||
pub control_file: Option<String>,
|
||||
pub create_images: bool,
|
||||
pub new_window: bool,
|
||||
pub two_paged: bool,
|
||||
pub version: bool,
|
||||
pub help: bool,
|
||||
}
|
||||
|
||||
impl Arguments {
|
||||
pub fn may_hand_off(&self) -> bool {
|
||||
!self.new_window && !self.files.is_empty() && self.options.is_empty() && self.control_file.is_none() && !self.create_images && !self.two_paged
|
||||
}
|
||||
|
||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Self {
|
||||
let mut parsed = Self::default();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--version" => {
|
||||
parsed.version = true;
|
||||
break;
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
parsed.help = true;
|
||||
break;
|
||||
}
|
||||
"--control" => parsed.control_file = args.next(),
|
||||
"--create-images" => parsed.create_images = true,
|
||||
"--new-window" => parsed.new_window = true,
|
||||
"--two-paged" => parsed.two_paged = true,
|
||||
flag if flag.starts_with("--two-paged=") => {
|
||||
// It has no value: do not consume the following filename on malformed use.
|
||||
log::warn!("{flag}: use --two-paged without a value");
|
||||
}
|
||||
flag if flag.starts_with("--") => {
|
||||
let value = args.next().unwrap_or_default();
|
||||
parsed.options.push((flag.trim_start_matches("--").to_string(), value));
|
||||
}
|
||||
_ => parsed.files.push(super::path_from_arg(a)),
|
||||
}
|
||||
}
|
||||
parsed
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn open_files(app: &mut PdfCraftApp, files: &[String], two_paged: bool) {
|
||||
for path in files {
|
||||
if two_paged {
|
||||
// The per-file override wins over initial view without changing saved defaults.
|
||||
app.open_path_with_display(path, pdfcraft_ui_egui::canvas::PageLayout::TwoUp, false);
|
||||
} else {
|
||||
app.open_path(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse(args: &[&str]) -> Arguments {
|
||||
Arguments::parse(args.iter().map(|s| s.to_string()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_flag_keeps_files_in_either_position() {
|
||||
for args in
|
||||
[["--two-paged", "first.pdf", "second.pdf"], ["first.pdf", "--two-paged", "second.pdf"], ["first.pdf", "second.pdf", "--two-paged"]]
|
||||
{
|
||||
let parsed = parse(&args);
|
||||
assert!(parsed.two_paged);
|
||||
assert_eq!(parsed.files, ["first.pdf", "second.pdf"]);
|
||||
assert!(parsed.options.is_empty());
|
||||
assert!(!parsed.may_hand_off());
|
||||
}
|
||||
let empty = parse(&["--two-paged"]);
|
||||
assert!(empty.two_paged && empty.files.is_empty());
|
||||
let normal = parse(&["first.pdf", "second.pdf"]);
|
||||
assert!(!normal.two_paged);
|
||||
assert!(normal.may_hand_off());
|
||||
for args in [
|
||||
&["--new-window", "first.pdf"][..],
|
||||
&["--layout", "two-page", "first.pdf"],
|
||||
&["--control", "control.json", "first.pdf"],
|
||||
&["--create-images", "first.png"],
|
||||
] {
|
||||
assert!(!parse(args).may_hand_off());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_layout_options_are_preserved_in_either_order() {
|
||||
for args in [["--two-paged", "--layout", "single", "file.pdf"], ["--layout", "single", "--two-paged", "file.pdf"]] {
|
||||
let parsed = parse(&args);
|
||||
assert!(parsed.two_paged);
|
||||
assert_eq!(parsed.files, ["file.pdf"]);
|
||||
assert_eq!(parsed.options, [("layout".into(), "single".into())]);
|
||||
}
|
||||
let parsed = parse(&["--two-paged", "--default-layout", "two-up", "file.pdf"]);
|
||||
assert_eq!(parsed.options, [("default-layout".into(), "two-up".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_options_and_version_are_preserved() {
|
||||
let parsed = parse(&["--two-paged", "--new-window", "--grid-zoom", "150", "--default-mode", "read", "file:///tmp/a%20b.pdf"]);
|
||||
assert!(parsed.two_paged && parsed.new_window);
|
||||
assert_eq!(parsed.files, ["/tmp/a b.pdf"]);
|
||||
assert_eq!(parsed.options, [("grid-zoom".into(), "150".into()), ("default-mode".into(), "read".into())]);
|
||||
assert!(!parsed.may_hand_off());
|
||||
let version = parse(&["--two-paged", "--version", "ignored.pdf"]);
|
||||
assert!(version.version && version.files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_keeps_the_headless_early_exit() {
|
||||
for flag in ["-h", "--help"] {
|
||||
let args = parse(&["--two-paged", flag, "ignored.pdf"]);
|
||||
assert!(args.help && args.files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_boolean_does_not_eat_a_file_or_option() {
|
||||
let args = parse(&["--two-paged=false", "first.pdf", "--layout", "single", "second.pdf"]);
|
||||
assert!(!args.two_paged);
|
||||
assert_eq!(args.files, ["first.pdf", "second.pdf"]);
|
||||
assert_eq!(args.options, [("layout".into(), "single".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_override_is_per_file_and_does_not_persist() {
|
||||
use std::io::Write as _;
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-two-paged-{}-{nonce}.pdf", std::process::id()));
|
||||
let mut file = std::fs::OpenOptions::new().create_new(true).write(true).open(&path).unwrap();
|
||||
file.write_all(include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
|
||||
drop(file);
|
||||
let second_path = path.with_extension("second.pdf");
|
||||
std::fs::copy(&path, &second_path).unwrap();
|
||||
let text = path.to_str().unwrap();
|
||||
let second = second_path.to_str().unwrap();
|
||||
for enabled in [false, true] {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "single").unwrap();
|
||||
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
|
||||
let restored = app.views[0].id;
|
||||
open_files(&mut app, &[text.into(), second.into()], enabled);
|
||||
assert_eq!(app.views.len(), 3);
|
||||
assert_eq!(app.views[0].id, restored);
|
||||
assert_eq!(app.views[0].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||||
for view in app.views.iter().skip(1) {
|
||||
assert_eq!(
|
||||
view.layout,
|
||||
if enabled { pdfcraft_ui_egui::canvas::PageLayout::TwoUp } else { pdfcraft_ui_egui::canvas::PageLayout::Single }
|
||||
);
|
||||
assert!(!view.continuous());
|
||||
}
|
||||
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
|
||||
assert_eq!(persisted["default_layout"], "single");
|
||||
assert!(persisted.get("two_paged").is_none());
|
||||
// Existing --layout applies after the flag to the active document only.
|
||||
if enabled {
|
||||
app.set_launch_layout("continuous").unwrap();
|
||||
} else {
|
||||
app.set_option("layout", "continuous").unwrap();
|
||||
}
|
||||
assert_eq!(app.views[2].layout, pdfcraft_ui_egui::canvas::PageLayout::Single);
|
||||
assert!(app.views[2].continuous());
|
||||
app.set_option("default-layout", "two-up").unwrap();
|
||||
assert_eq!(app.view_defaults.layout, pdfcraft_ui_egui::canvas::PageLayout::TwoUp);
|
||||
assert!(app.view_defaults.continuous);
|
||||
open_files(&mut app, &[], enabled);
|
||||
assert_eq!(app.views.len(), 3);
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(second_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_launch_layout_preserves_argument_order_and_active_file() {
|
||||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-explicit-layout-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
|
||||
let second_path = path.with_extension("second.pdf");
|
||||
std::fs::copy(&path, &second_path).unwrap();
|
||||
let names = [path.to_str().unwrap(), second_path.to_str().unwrap()];
|
||||
for prefix in [["--two-paged", "--layout", "single"], ["--layout", "single", "--two-paged"]] {
|
||||
for count in [1, 2] {
|
||||
let args = Arguments::parse(prefix.into_iter().chain(names.iter().take(count).copied()).map(str::to_owned));
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_bytes("restored.pdf", None, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf").to_vec()).unwrap();
|
||||
open_files(&mut app, &args.files, args.two_paged);
|
||||
for (key, value) in args.options {
|
||||
assert_eq!(key, "layout");
|
||||
app.set_launch_layout(&value).unwrap();
|
||||
}
|
||||
assert_eq!(app.active, Some(count));
|
||||
assert!(app.views[0].continuous());
|
||||
for view in app.views.iter().skip(1).take(count - 1) {
|
||||
assert_eq!(view.layout, PageLayout::TwoUp);
|
||||
}
|
||||
assert_eq!(app.views[count].layout, PageLayout::Single);
|
||||
assert!(!app.views[count].continuous());
|
||||
assert!(app.view_defaults.continuous);
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(second_path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod repeated_file_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_repeated_file_reuses_its_tab_and_receives_the_launch_override() {
|
||||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||||
let path = std::env::temp_dir().join(format!("pdfcraft-repeated-startup-{}.pdf", std::process::id()));
|
||||
std::fs::write(&path, include_bytes!("../../../crates/ui-egui/tests/data/form.pdf")).unwrap();
|
||||
let name = path.to_str().unwrap().to_string();
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_path(&name);
|
||||
let original = app.views[0].id;
|
||||
open_files(&mut app, &[name.clone(), name], true);
|
||||
assert_eq!(app.views.len(), 1);
|
||||
assert_eq!(app.views[0].id, original);
|
||||
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
|
||||
assert!(!app.views[0].continuous());
|
||||
app.set_launch_layout("single").unwrap();
|
||||
assert_eq!(app.views[0].layout, PageLayout::Single);
|
||||
assert!(app.view_defaults.continuous);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,10 @@ impl PdfCraftApp {
|
||||
pending_opens: Vec::new(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
next_open_token: 0,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
launch_layout: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
launch_display_target: None,
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
|
||||
@@ -117,6 +117,7 @@ impl PdfCraftApp {
|
||||
after_open: request.after_open,
|
||||
recovered: request.recovered,
|
||||
then: request.then,
|
||||
display_override: None,
|
||||
});
|
||||
Ok(token)
|
||||
}
|
||||
@@ -147,6 +148,7 @@ impl PdfCraftApp {
|
||||
// earlier waiting replies, without consuming a blocked receiver's result.
|
||||
waiting.append(&mut self.pending_opens);
|
||||
self.pending_opens = waiting;
|
||||
self.settle_launch_layout();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -182,6 +184,12 @@ impl PdfCraftApp {
|
||||
if let Some(destination) = pending.after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some((layout, continuous)) = pending.display_override
|
||||
&& let Some(view) = self.views.iter_mut().find(|view| view.id == id)
|
||||
{
|
||||
view.set_display(layout, continuous);
|
||||
self.launch_display_target = Some(id);
|
||||
}
|
||||
if let Some(meta) = &pending.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
@@ -205,6 +213,7 @@ impl PdfCraftApp {
|
||||
recovered: pending.recovered,
|
||||
then: pending.then,
|
||||
after_open: pending.after_open,
|
||||
display_override: pending.display_override,
|
||||
});
|
||||
}
|
||||
Err(error) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &pending.name), ("e", &error.to_string())]),
|
||||
@@ -239,6 +248,7 @@ impl PdfCraftApp {
|
||||
recovered: None,
|
||||
then: None,
|
||||
after_open: None,
|
||||
display_override: None,
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
@@ -416,6 +426,8 @@ impl PdfCraftApp {
|
||||
if let Some(meta) = p.recovered {
|
||||
self.recoverable.push(meta);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.settle_launch_layout();
|
||||
return;
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -431,6 +443,11 @@ impl PdfCraftApp {
|
||||
recovered: p.recovered.clone(),
|
||||
then: p.then,
|
||||
});
|
||||
if let Ok(token) = &result
|
||||
&& let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == *token)
|
||||
{
|
||||
pending.display_override = p.display_override;
|
||||
}
|
||||
if let Err(error) = result {
|
||||
let mut prompt = p;
|
||||
prompt.error = Some(error);
|
||||
@@ -458,6 +475,15 @@ impl PdfCraftApp {
|
||||
if let Some(destination) = after_open {
|
||||
self.apply_remote_destination(destination);
|
||||
}
|
||||
if let Some((layout, continuous)) = p.display_override
|
||||
&& let Some(view) = self.views.last_mut()
|
||||
{
|
||||
view.set_display(layout, continuous);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.launch_display_target = Some(view.id);
|
||||
}
|
||||
}
|
||||
if let Some(meta) = &p.recovered {
|
||||
self.finish_recovery(meta);
|
||||
}
|
||||
@@ -472,9 +498,12 @@ impl PdfCraftApp {
|
||||
prompt.recovered = p.recovered;
|
||||
prompt.then = p.then;
|
||||
prompt.after_open = after_open;
|
||||
prompt.display_override = p.display_override;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.settle_launch_layout();
|
||||
}
|
||||
|
||||
/// Ask for the active document's permissions password, then run `then` (if any).
|
||||
@@ -533,17 +562,37 @@ impl PdfCraftApp {
|
||||
/// the file opened, focused its tab, was handed to the background worker, or failed.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn open_path_then(&mut self, path: &str, then: Option<&'static str>) -> OpenOutcome {
|
||||
self.open_path_request(path, then, None)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_path_request(&mut self, path: &str, then: Option<&'static str>, display_override: Option<(canvas::PageLayout, bool)>) -> OpenOutcome {
|
||||
// One file, one tab (#2166): a file that is open already comes to the front instead,
|
||||
// and the tool it was opened for runs on it. One still being opened is left to that.
|
||||
if let Some(i) = self.tab_showing(path) {
|
||||
self.startup_superseded = true;
|
||||
self.active = Some(i);
|
||||
if let Some((layout, continuous)) = display_override
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.set_display(layout, continuous);
|
||||
self.launch_display_target = Some(view.id);
|
||||
}
|
||||
if let Some(command) = then {
|
||||
self.execute(command);
|
||||
}
|
||||
return OpenOutcome::Focused;
|
||||
}
|
||||
if self.pending_opens.iter().any(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
|
||||
if let Some(pending) = self.pending_opens.iter_mut().find(|p| p.path.as_deref().is_some_and(|p| same_file(p, path))) {
|
||||
if display_override.is_some() {
|
||||
pending.display_override = display_override;
|
||||
}
|
||||
return OpenOutcome::Pending;
|
||||
}
|
||||
if display_override.is_some()
|
||||
&& let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref().is_some_and(|p| same_file(p, path)))
|
||||
{
|
||||
prompt.display_override = display_override;
|
||||
return OpenOutcome::Pending;
|
||||
}
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string());
|
||||
@@ -552,18 +601,29 @@ impl PdfCraftApp {
|
||||
// Before the first frame there is no interaction to keep responsive; this also preserves
|
||||
// deterministic inline startup and test flows. Once live, reading and preparation use
|
||||
// the bounded worker and commit on the UI thread.
|
||||
if self.ctx.is_none() || self.run_inline {
|
||||
// Keep flagged launch files behind an existing password prompt or picker.
|
||||
if (self.ctx.is_none() || self.run_inline) && !(display_override.is_some() && (self.modal_open() || self.pickers.pending())) {
|
||||
match std::fs::read(path) {
|
||||
Ok(bytes) => {
|
||||
let before = self.views.len();
|
||||
let previous_prompt = self.password_prompt.as_ref().map(|p| p.bytes.clone());
|
||||
if let Err(error) = self.open_bytes(&name, Some(path.to_string()), bytes) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
return OpenOutcome::Failed;
|
||||
}
|
||||
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path)) {
|
||||
if let Some(prompt) = self.password_prompt.as_mut().filter(|p| {
|
||||
p.path.as_deref() == Some(path) && previous_prompt.as_ref().is_none_or(|bytes| !std::sync::Arc::ptr_eq(bytes, &p.bytes))
|
||||
}) {
|
||||
// Encrypted: the tool runs once the password is in.
|
||||
prompt.then = then;
|
||||
prompt.display_override = display_override;
|
||||
} else if self.views.len() > before {
|
||||
if let Some((layout, continuous)) = display_override
|
||||
&& let Some(view) = self.views.last_mut()
|
||||
{
|
||||
view.set_display(layout, continuous);
|
||||
self.launch_display_target = Some(view.id);
|
||||
}
|
||||
if let Some(doc) = self.views.last().map(|view| view.id) {
|
||||
self.watch.known_as(doc, stamp);
|
||||
}
|
||||
@@ -595,7 +655,12 @@ impl PdfCraftApp {
|
||||
recovered: None,
|
||||
then,
|
||||
}) {
|
||||
Ok(_) => OpenOutcome::Pending,
|
||||
Ok(token) => {
|
||||
if let Some(pending) = self.pending_opens.iter_mut().find(|pending| pending.token == token) {
|
||||
pending.display_override = display_override;
|
||||
}
|
||||
OpenOutcome::Pending
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &error)]);
|
||||
OpenOutcome::Failed
|
||||
@@ -604,6 +669,38 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply an explicit launch layout after the command-line display overrides settle.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_launch_layout(&mut self, value: &str) -> Result<(), String> {
|
||||
let (layout, continuous) = canvas::parse_display_token(value).ok_or("layout must be single, continuous, two-up or two-page")?;
|
||||
self.launch_layout = Some((layout, continuous));
|
||||
self.settle_launch_layout();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn settle_launch_layout(&mut self) {
|
||||
if self.pending_opens.iter().any(|open| open.display_override.is_some())
|
||||
|| self.password_prompt.as_ref().is_some_and(|prompt| prompt.display_override.is_some())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Follow the existing completion/activation order, not a guessed tab index or path.
|
||||
// Failed or cancelled opens cannot cause the layout to land on a restored document.
|
||||
if let Some((layout, continuous)) = self.launch_layout.take()
|
||||
&& let Some(id) = self.launch_display_target
|
||||
&& let Some(view) = self.views.iter_mut().find(|view| view.id == id)
|
||||
{
|
||||
view.set_display(layout, continuous);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open one native file with a transient display override, including after a password prompt.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn open_path_with_display(&mut self, path: &str, layout: canvas::PageLayout, continuous: bool) {
|
||||
self.open_path_request(path, None, Some((layout, continuous)));
|
||||
}
|
||||
|
||||
/// The File ▸ Open picker: PDFs, and images and text that become PDFs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn open_picker() -> rfd::AsyncFileDialog {
|
||||
|
||||
@@ -239,6 +239,7 @@ struct PendingOpen {
|
||||
after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
recovered: Option<RecoveryMeta>,
|
||||
then: Option<&'static str>,
|
||||
display_override: Option<(canvas::PageLayout, bool)>,
|
||||
}
|
||||
|
||||
pub struct PdfCraftApp {
|
||||
@@ -368,6 +369,11 @@ pub struct PdfCraftApp {
|
||||
pending_opens: Vec<PendingOpen>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
next_open_token: u64,
|
||||
/// Explicit CLI layout waits for flagged launch opens, never targeting restored tabs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
launch_layout: Option<(canvas::PageLayout, bool)>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
launch_display_target: Option<DocId>,
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: Option<std::sync::Arc<open_worker::PrepareGate>>,
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
|
||||
@@ -733,6 +733,146 @@ fn startup_files_wait_for_absolute_link_consent_and_preparation() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_layout_waits_for_cli_opens_and_preserves_restored_tabs() {
|
||||
use crate::canvas::PageLayout;
|
||||
// Controlled worker replies exercise both completion orders without scheduling races.
|
||||
for reverse in [false, true] {
|
||||
for count in [1, 2] {
|
||||
for restored in [false, true] {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
if restored {
|
||||
app.open_bytes("restored.pdf", None, fixture(1)).unwrap();
|
||||
}
|
||||
let offset = usize::from(restored);
|
||||
let mut replies = Vec::new();
|
||||
for token in 0..count {
|
||||
let name = format!("cli-{token}.pdf");
|
||||
let bytes = Arc::new(fixture(2));
|
||||
let prepared = app.session.prepare_open(&name, None, bytes.clone(), None);
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
app.pending_opens.push(crate::PendingOpen {
|
||||
token,
|
||||
receiver,
|
||||
name,
|
||||
path: None,
|
||||
stamp: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
display_override: Some((PageLayout::TwoUp, false)),
|
||||
});
|
||||
replies.push((
|
||||
sender,
|
||||
open_worker::ResultMessage { token, kind: open_worker::ResultKind::Prepared { bytes, result: Box::new(prepared) } },
|
||||
));
|
||||
}
|
||||
app.set_launch_layout("single").unwrap();
|
||||
if restored {
|
||||
assert!(app.views[0].continuous(), "pending --layout must not target the restored tab");
|
||||
}
|
||||
if reverse {
|
||||
replies.reverse();
|
||||
}
|
||||
for (index, (sender, reply)) in replies.into_iter().enumerate() {
|
||||
let name = format!("cli-{}.pdf", reply.token);
|
||||
sender.send(reply).unwrap();
|
||||
app.poll_pending_opens();
|
||||
let view = &app.views[index + offset];
|
||||
assert_eq!(view.layout, if index + 1 == count as usize { PageLayout::Single } else { PageLayout::TwoUp });
|
||||
assert!(!view.continuous());
|
||||
assert_eq!(app.session.get(view.id).unwrap().name, name);
|
||||
}
|
||||
assert_eq!(app.active, Some(count as usize + offset - 1));
|
||||
if restored {
|
||||
assert_eq!(app.views[0].layout, PageLayout::Single);
|
||||
assert!(app.views[0].continuous());
|
||||
}
|
||||
assert_eq!(app.view_defaults.layout, PageLayout::Single);
|
||||
assert!(app.view_defaults.continuous);
|
||||
assert_eq!(serde_json::from_str::<Value>(&app.persist()).unwrap()["default_layout"], "continuous");
|
||||
// Ordinary later opens cannot inherit either launch override.
|
||||
app.open_bytes("later.pdf", None, fixture(1)).unwrap();
|
||||
assert!(app.views.last().unwrap().continuous());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_layout_failed_or_cancelled_opens_do_not_target_existing_documents() {
|
||||
use crate::canvas::PageLayout;
|
||||
for cancel in [false, true] {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_bytes("restored.pdf", None, fixture(1)).unwrap();
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
app.pending_opens.push(crate::PendingOpen {
|
||||
token: 0,
|
||||
receiver,
|
||||
name: "cli.pdf".into(),
|
||||
path: None,
|
||||
stamp: None,
|
||||
activate: true,
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
display_override: Some((PageLayout::TwoUp, false)),
|
||||
});
|
||||
app.set_launch_layout("single").unwrap();
|
||||
assert!(app.set_launch_layout("invalid").is_err());
|
||||
let kind = if cancel {
|
||||
open_worker::ResultKind::Prepared { bytes: Arc::new(fixture(1)), result: Box::new(Err(pdfcraft_render::OpenError::NeedsPassword)) }
|
||||
} else {
|
||||
open_worker::ResultKind::ReadFailed("synthetic read failure".into())
|
||||
};
|
||||
sender.send(open_worker::ResultMessage { token: 0, kind }).unwrap();
|
||||
app.poll_pending_opens();
|
||||
if cancel {
|
||||
app.submit_password(None);
|
||||
}
|
||||
assert!(app.launch_layout.is_none());
|
||||
assert_eq!(app.views.len(), 1);
|
||||
assert!(app.views[0].continuous());
|
||||
app.open_bytes("later.pdf", None, fixture(1)).unwrap();
|
||||
assert!(app.views[1].continuous());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flagged_startup_opens_wait_for_password_ownership_before_the_first_frame() {
|
||||
use crate::canvas::PageLayout;
|
||||
let locked = tool_open_file("launch-locked", "pdf", &encrypted_tool_fixture());
|
||||
let second = tool_open_file("launch-second", "pdf", &fixture(2));
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_path_with_display(locked.0.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
let bytes = app.password_prompt.as_ref().unwrap().bytes.clone();
|
||||
app.open_path_with_display(locked.0.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
assert!(app.pending_opens.is_empty(), "repeating the locked file keeps its original prompt");
|
||||
app.open_path_with_display(second.0.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
assert_eq!(app.pending_opens.len(), 1);
|
||||
assert!(Arc::ptr_eq(&app.password_prompt.as_ref().unwrap().bytes, &bytes));
|
||||
app.set_launch_layout("single").unwrap();
|
||||
app.submit_password(Some("wrong".into()));
|
||||
assert!(app.password_prompt.as_ref().unwrap().error.is_some());
|
||||
app.submit_password(Some("tool-password".into()));
|
||||
assert_eq!(app.views.len(), 1);
|
||||
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while !app.pending_opens.is_empty() {
|
||||
assert!(std::time::Instant::now() < deadline);
|
||||
app.poll_pending_opens();
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
assert_eq!(app.views.len(), 2);
|
||||
assert_eq!(app.views[1].layout, PageLayout::Single);
|
||||
assert!(!app.views[1].continuous());
|
||||
assert!(app.view_defaults.continuous);
|
||||
}
|
||||
|
||||
/// #1928: a new file handed over by the OS (Finder, or another launch) raises the window at once,
|
||||
/// before it is prepared, and is the selected tab once it opens.
|
||||
#[test]
|
||||
|
||||
@@ -376,6 +376,7 @@ mod tests {
|
||||
after_open: None,
|
||||
recovered: None,
|
||||
then: None,
|
||||
display_override: None,
|
||||
});
|
||||
let got = Arc::new(Mutex::new(Vec::new()));
|
||||
app.pickers.showing.store(true, Ordering::SeqCst);
|
||||
|
||||
@@ -329,6 +329,8 @@ pub struct PasswordPrompt {
|
||||
pub(crate) then: Option<&'static str>,
|
||||
/// A link destination to apply after the linked PDF is unlocked.
|
||||
pub(crate) after_open: Option<pdfcraft_render::RemoteDestination>,
|
||||
/// A launch-time display override, applied only after this PDF is unlocked.
|
||||
pub(crate) display_override: Option<(canvas::PageLayout, bool)>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -2602,6 +2602,131 @@ fn encrypted(bytes: Vec<u8>, algorithm: pdfcraft_cos::Algorithm, user: &str, own
|
||||
pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_display_override_survives_password_retry_without_changing_defaults() {
|
||||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||||
use std::io::Write as _;
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = temp_path(&format!("startup-layout-{nonce}.pdf"));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
file.write_all(&protected("reader", "owner", -4)).unwrap();
|
||||
drop(file);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
assert!(app.views.is_empty() && app.password_prompt.is_some());
|
||||
app.submit_password(Some("wrong".into()));
|
||||
assert!(app.views.is_empty() && app.password_prompt.is_some());
|
||||
app.submit_password(Some("reader".into()));
|
||||
assert_eq!(app.views.len(), 1);
|
||||
assert_eq!(app.views[0].layout, PageLayout::TwoUp);
|
||||
assert!(!app.views[0].continuous());
|
||||
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
|
||||
assert_eq!(persisted["default_layout"], "continuous");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_launch_display_override_survives_retries_and_cancel() {
|
||||
asynchronous_launch_display(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asynchronous_launch_display_explicit_layout_survives_retries_and_cancel() {
|
||||
asynchronous_launch_display(true);
|
||||
}
|
||||
|
||||
fn asynchronous_launch_display(explicit: bool) {
|
||||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||||
fn wait(h: &mut Harness<'static, PdfCraftApp>, stage: &str, ready: impl Fn(&PdfCraftApp) -> bool) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while !ready(h.state()) {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"{stage}: views={}, prompt={}, error={}, notice={}",
|
||||
h.state().views.len(),
|
||||
h.state().password_prompt.is_some(),
|
||||
h.state().password_prompt.as_ref().is_some_and(|p| p.error.is_some()),
|
||||
h.state().toast.is_some()
|
||||
);
|
||||
h.run_steps(2);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
let path = temp_path(&format!("async-startup-layout-{explicit}.pdf"));
|
||||
std::fs::write(&path, protected("reader", "owner", -4)).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_bytes("other.pdf", None, fixture(2)).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert!(!h.state().run_inline, "exercise upstream's actual background-opening path");
|
||||
let other = h.state().views[0].id;
|
||||
h.state_mut().open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
if explicit {
|
||||
h.state_mut().set_launch_layout("single").unwrap();
|
||||
}
|
||||
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
|
||||
// The existing document keeps its default view while this request is pending.
|
||||
h.state_mut().submit_password(Some("wrong".into()));
|
||||
wait(&mut h, "wrong-password result", |app| app.password_prompt.as_ref().is_some_and(|p| p.error.is_some()));
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
h.state_mut().submit_password(Some("reader".into()));
|
||||
wait(&mut h, "first unlock", |app| app.views.len() == 2);
|
||||
assert_eq!(h.state().views[0].id, other);
|
||||
assert_eq!(h.state().views[0].layout, PageLayout::Single);
|
||||
assert!(h.state().views[0].continuous());
|
||||
assert_eq!(h.state().views[1].layout, if explicit { PageLayout::Single } else { PageLayout::TwoUp });
|
||||
assert!(!h.state().views[1].continuous());
|
||||
let second_path = path.with_extension("second.pdf");
|
||||
std::fs::copy(&path, &second_path).unwrap();
|
||||
h.state_mut().open_path_with_display(second_path.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
|
||||
h.state_mut().submit_password(None);
|
||||
h.state_mut().open_path(second_path.to_str().unwrap());
|
||||
wait(&mut h, "password prompt", |app| app.password_prompt.is_some());
|
||||
h.state_mut().submit_password(Some("reader".into()));
|
||||
wait(&mut h, "normal unlock after cancel", |app| app.views.len() == 3);
|
||||
assert_eq!(h.state().views[2].layout, PageLayout::Single);
|
||||
assert!(h.state().views[2].continuous());
|
||||
let persisted: serde_json::Value = serde_json::from_str(&h.state().persist()).unwrap();
|
||||
assert_eq!(persisted["default_layout"], "continuous");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
std::fs::remove_file(second_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_launch_display_override_does_not_affect_later_documents() {
|
||||
use pdfcraft_ui_egui::canvas::PageLayout;
|
||||
use std::io::Write as _;
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = temp_path(&format!("startup-cancel-layout-{nonce}.pdf"));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
file.write_all(&protected("reader", "owner", -4)).unwrap();
|
||||
drop(file);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("default-layout", "continuous").unwrap();
|
||||
app.open_path_with_display(path.to_str().unwrap(), PageLayout::TwoUp, false);
|
||||
assert!(app.views.is_empty() && app.password_prompt.is_some());
|
||||
app.submit_password(None);
|
||||
assert!(app.views.is_empty() && app.password_prompt.is_none());
|
||||
app.open_bytes("next.pdf", None, fixture(2)).unwrap();
|
||||
assert_eq!(app.views[0].layout, PageLayout::Single);
|
||||
assert!(app.views[0].continuous());
|
||||
// Reopening the same protected file normally must not resurrect the canceled override.
|
||||
app.open_path(path.to_str().unwrap());
|
||||
app.submit_password(Some("reader".into()));
|
||||
assert_eq!(app.views.len(), 2);
|
||||
assert_eq!(app.views[1].layout, PageLayout::Single);
|
||||
assert!(app.views[1].continuous());
|
||||
let persisted: serde_json::Value = serde_json::from_str(&app.persist()).unwrap();
|
||||
assert_eq!(persisted["default_layout"], "continuous");
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_prompt_opens_and_security_tab_reports_the_details() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
|
||||
+2
-1
@@ -194,7 +194,7 @@ total matches the parity estimate.
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Optional --two-paged applies noncontinuous two-page layout to explicitly opened CLI files without changing defaults; explicit --layout wins. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel<br>Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
|
||||
@@ -290,6 +290,7 @@ Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | patch | M3 #1354: reconcile the launch override with split app/file modules and the structured open result; preserve headless help/version exits. CI validation pending. |
|
||||
| 2026-10-31 | patch | M2, PR #4172: eight GBK font/alias cases compare exact pixels with independent installed-face and explicit-Unicode references; wrong-glyph and wrong-family negative controls fail. Estimates unchanged. |
|
||||
| 2026-10-31 | patch | M2, PR #4172: integrate main fa5f9f8c, preserve both raster test groups and restore explicit display_config imports; display-only CJK policy and estimates unchanged. |
|
||||
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
|
||||
|
||||
@@ -890,8 +890,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["view.layout.two_up"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time"]
|
||||
notes = "next/previous page step a spread at a time"
|
||||
evidence = ["crates/ui-egui/tests/it/view.rs::two_up_layout_places_pages_side_by_side", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "apps/pdfcraft/src/startup.rs::startup_override_is_per_file_and_does_not_persist", "crates/ui-egui/src/platform/open_worker/tests.rs::flagged_startup_opens_wait_for_password_ownership_before_the_first_frame"]
|
||||
notes = "Next/previous page step a spread at a time. Optional desktop --two-paged launches named PDFs in noncontinuous two-page view without changing saved defaults or unrelated restored tabs. Explicit --layout wins after flagged opens settle, including password retries; repeated files reuse their tab. This is a launch override, not a declarative configuration module."
|
||||
|
||||
[[feature]]
|
||||
id = "view.layout-two-up-scrolling"
|
||||
|
||||
Reference in New Issue
Block a user