From 95a769eedc44bcfb33a18e95b9c61b49e41bfd61 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 22:55:19 -0300 Subject: [PATCH 01/42] Open: opening an already-open file focuses its tab (#1620) Co-authored-by: GLM --- crates/ui-egui/src/last_session.rs | 2 +- crates/ui-egui/src/lib.rs | 16 ++++++++++++++++ crates/ui-egui/tests/editing.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/last_session.rs b/crates/ui-egui/src/last_session.rs index bf64ea7ab..7bc4833c0 100644 --- a/crates/ui-egui/src/last_session.rs +++ b/crates/ui-egui/src/last_session.rs @@ -52,7 +52,7 @@ impl LastSession { /// `path` made absolute against the current directory, without resolving links (and without /// Windows' `\\?\` prefix, which `canonicalize` adds). #[cfg(not(target_arch = "wasm32"))] -fn absolute(path: &str) -> String { +pub(crate) fn absolute(path: &str) -> String { std::path::absolute(path).map_or_else(|_| path.to_string(), |p| p.to_string_lossy().into_owned()) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 91d534802..f34fd63de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1208,8 +1208,24 @@ impl PdfCraftApp { } } + /// The tab already showing the file at `path`, if any: its position in `views`. Paths are + /// compared after `absolute` normalization, so another spelling of the same file matches + /// (#1620). + #[cfg(not(target_arch = "wasm32"))] + fn view_showing(&self, path: &str) -> Option { + let path = crate::last_session::absolute(path); + self.views + .iter() + .position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(crate::last_session::absolute).as_deref() == Some(path.as_str())) + } + #[cfg(not(target_arch = "wasm32"))] pub fn open_path(&mut self, path: &str) { + if let Some(i) = self.view_showing(path) { + self.startup_superseded = true; + self.active = Some(i); + return; + } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); match std::fs::read(path) { Ok(bytes) => { diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 17990d2fb..6a177831d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -653,6 +653,36 @@ fn the_last_session_is_off_by_default_and_skips_missing_and_duplicate_files() { assert!(later.views.is_empty(), "neither opens from the session"); } +#[test] +fn opening_the_same_path_again_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-open"); + let a = pdfs.file("a.pdf", 1); + let b = pdfs.file("b.pdf", 1); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&a); + h.state_mut().open_path(&b); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "two different files open two tabs"); + h.state_mut().open_path(&a); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "opening an open file again adds no tab"); + assert_eq!(h.state().active, Some(0), "the existing tab comes to the front"); + assert_eq!(open_paths(h.state()), vec![a, b], "the tab order is untouched"); +} + +#[test] +fn opening_the_same_file_with_a_dot_component_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-dot"); + let a = pdfs.file("a.pdf", 1); + let dotted = format!("{}/./a.pdf", pdfs.0.display()); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&a); + h.state_mut().open_path(&dotted); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1, "another spelling of the same file matches"); + assert_eq!(h.state().active, Some(0)); +} + #[test] fn save_prompt_stays_inside_the_screen_for_a_long_filename() { // Issue #161: an unwrapped title carrying a long filename widened the centered modal past From a5e16bf556896f9d6ae435f18f29feccf7929c4a Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:12:13 -0300 Subject: [PATCH 02/42] Open Recent: focus an already-open file through the shared lookup (#1620) Co-authored-by: GLM --- crates/ui-egui/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f34fd63de..d7f33abbc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1200,11 +1200,13 @@ impl PdfCraftApp { /// Open a file from a recent list: focus the tab already showing it, else open it (File ▸ /// Open Recent and the Home view's list share this). pub fn open_recent(&mut self, path: &str) { - if let Some(i) = self.views.iter().position(|v| self.session.get(v.id).and_then(|d| d.path.as_deref()) == Some(path)) { - self.active = Some(i); - } else { - #[cfg(not(target_arch = "wasm32"))] - self.open_path(path); + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(i) = self.view_showing(path) { + self.active = Some(i); + } else { + self.open_path(path); + } } } From a7160cf45c7291fdecf46c39e0b8a554858ea3f6 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:21:23 -0300 Subject: [PATCH 03/42] Drop: dropping an already-open document focuses its tab (#1620) Co-authored-by: GLM --- crates/ui-egui/tests/ui.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 38cde809b..1e2df54bc 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -616,6 +616,29 @@ fn dropping_a_pdf_on_the_window_opens_it() { h.get_by_label_contains("dropped.pdf"); } +#[test] +fn dropping_an_open_pdf_again_focuses_its_tab() { + let dir = std::env::temp_dir().join(format!("pdfcraft-drop-dedup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("dropped.pdf"); + std::fs::write(&path, FIXTURE).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app + }); + h.run_steps(3); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped { path: path.clone(), bytes: FIXTURE.to_vec() }); + h.input_mut().dropped_files.push(file.clone()); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1, "the dropped PDF opens in a tab"); + h.input_mut().dropped_files.push(file); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1, "dropping it again adds no tab"); + std::fs::remove_dir_all(&dir).unwrap(); +} + #[test] fn a_file_dropped_on_the_page_grid_is_read_from_disk_and_inserted() { // The grid's drop reads a file from its path when its turn to be inserted comes, and inserts it From c7fbcd3fb68c81f468d3244ce583d68b42cc8185 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:36:55 -0300 Subject: [PATCH 04/42] Open: focus edge cases - vanished file, encrypted prompt, pathless documents (#1620) Co-authored-by: GLM --- crates/ui-egui/tests/editing.rs | 45 +++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 6a177831d..215eaedc5 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -683,6 +683,51 @@ fn opening_the_same_file_with_a_dot_component_focuses_its_tab() { assert_eq!(h.state().active, Some(0)); } +#[test] +fn opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab() { + let pdfs = TempPdfs::new("dedup-vanished"); + let a = pdfs.file("a.pdf", 1); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&a); + h.run_steps(2); + std::fs::remove_file(&a).unwrap(); + h.state_mut().open_path(&a); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1, "no second tab"); + assert_eq!(h.state().active, Some(0), "the open tab is focused"); + assert!(h.state().toast.is_none(), "no read error: the file is never read again"); +} + +#[test] +fn opening_an_encrypted_document_twice_keeps_one_prompt_and_one_tab() { + let pdfs = TempPdfs::new("dedup-encrypted"); + let secret = pdfs.0.join("secret.pdf"); + std::fs::write(&secret, protected("pw", "owner", -1)).unwrap(); + let s = secret.to_string_lossy().into_owned(); + let mut h = session_harness(|_| {}); + h.state_mut().open_path(&s); + h.state_mut().open_path(&s); + h.run_steps(2); + assert!(h.state().views.is_empty(), "still locked"); + let field = h.get_by_role(Role::PasswordInput); + field.focus(); + field.type_text("pw"); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "unlocking opens one tab, not two"); +} + +#[test] +fn opening_documents_without_a_path_still_open_twice() { + let bytes = fixture(1); + let mut h = session_harness(|_| {}); + h.state_mut().open_bytes("copy.pdf", None, bytes.clone()).unwrap(); + h.state_mut().open_bytes("copy.pdf", None, bytes).unwrap(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 2, "bytes have no path to compare (attachments, conversions)"); +} + #[test] fn save_prompt_stays_inside_the_screen_for_a_long_filename() { // Issue #161: an unwrapped title carrying a long filename widened the centered modal past From 09e32698859cb98690eef89a7f8ea09ced6a33b3 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Mon, 12 Oct 2026 23:44:33 -0300 Subject: [PATCH 05/42] Parity: an already-open file focuses its tab on every open path (#1620) Co-authored-by: GLM --- parity/acrobat-features.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e57815882..56a116286 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -22,8 +22,8 @@ milestone = "M1" status = "shipped" commands = ["file.open"] tools = ["doc_open"] -evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system"] -notes = "File ▸ Open, the command line (Linux and Windows file associations), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type)" +evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find", "crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/ui.rs::files_and_quit_from_the_operating_system", "crates/ui-egui/tests/editing.rs::opening_the_same_path_again_focuses_its_tab", "crates/ui-egui/tests/editing.rs::opening_a_file_whose_copy_vanished_from_disk_focuses_its_tab"] +notes = "File ▸ Open, the command line (Linux and Windows file associations), and on macOS Finder, Open With and the Dock (Apple events; the bundle declares PDF as a document type); an already-open file focuses its tab instead of opening a second copy (#1620)" [[feature]] id = "core.repair-broken-files" @@ -646,8 +646,8 @@ milestone = "M3" status = "shipped" commands = [] tools = [] -evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it"] -notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously" +evidence = ["crates/ui-egui/tests/ui.rs::dropping_a_pdf_on_the_window_opens_it", "crates/ui-egui/tests/ui.rs::dropping_an_open_pdf_again_focuses_its_tab"] +notes = "desktop: the dropped file opens in a tab (by path, or its bytes); web: read asynchronously; a dropped document that is already open focuses its tab (#1620)" [[feature]] id = "core.recent-files" @@ -659,7 +659,7 @@ status = "shipped" commands = ["file.open_recent", "file.clear_recent"] tools = [] evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"] -notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it; Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)" +notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it, from any open path (#1620); Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)" [[feature]] id = "core.reopen-last-session" From 305a5676ce67a90c84c2afaf872d03d5adafbba1 Mon Sep 17 00:00:00 2001 From: Eduardo Soares Date: Tue, 13 Oct 2026 00:25:37 -0300 Subject: [PATCH 06/42] Open: programmatic callers see focus vs open vs failure (#1620) Final-review fixes for the focus-existing-tab branch: - open_path returns OpenOutcome (Opened | Focused | Failed), so callers no longer infer success by counting views. Toasts are unchanged; the value is the programmatic contract. - The control channel's ui open replies Ok with "focused": true when the file's tab was already open (an agent driving the app headlessly read the old Err as a read failure), and Err only on Failed. - open_then_run runs the picked tool on Focused too: picking an already-open file focuses its tab and the command runs, as before the branch. - open_recent silences the wasm-only unused `path` binding, like reopen_last_files. Co-authored-by: GLM --- crates/ui-egui/src/control.rs | 13 +++++------ crates/ui-egui/src/lib.rs | 40 +++++++++++++++++++++++++------- crates/ui-egui/src/pickers.rs | 4 +++- crates/ui-egui/tests/commands.rs | 27 +++++++++++++++++++++ crates/ui-egui/tests/control.rs | 35 ++++++++++++++++++++++++++++ crates/ui-egui/tests/editing.rs | 30 +++++++++++++++++++----- 6 files changed, 126 insertions(+), 23 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bba7108c0..5f43f6c7f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -33,7 +33,8 @@ //! for this session without changing the saved preference. `ui.state.default_mode` reports it. //! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference` //! reports the choice and `ui.state.theme` reports the resolved light/dark colours. -//! - `ui.open {path}`: open a file. +//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it +//! (`"focused": true`) instead of adding a tab; only a failed open replies `Err`. //! - `ui.screenshot {region?}`: PNG of the window (base64), optionally cropped to a rect. use std::collections::{HashMap, VecDeque}; @@ -735,12 +736,10 @@ impl Host for crate::PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] fn open(&mut self, path: &str) -> Reply { - let before = self.views.len(); - self.open_path(path); - if self.views.len() > before || self.password_prompt.is_some() { - Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })) - } else { - Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))) + match self.open_path(path) { + crate::OpenOutcome::Opened => Ok(json!({ "opened": path, "password_prompt": self.password_prompt.is_some() })), + crate::OpenOutcome::Focused => Ok(json!({ "opened": path, "focused": true })), + crate::OpenOutcome::Failed => Err(self.toast.as_ref().map(|t| t.0.clone()).unwrap_or_else(|| format!("couldn't open {path}"))), } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d7f33abbc..afb19f5e5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -734,6 +734,19 @@ fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> { Some(out) } +/// What [`PdfCraftApp::open_path`] did: opened the file in a new tab, focused the tab already +/// showing it, or failed. Failures tell the user through a toast; this is the programmatic +/// contract for callers that must know (the control channel, tools that wait for a file) (#1620). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OpenOutcome { + /// The file opened in a new tab (or raised its password prompt). + Opened, + /// A tab already showed the file; it was focused and no tab was added. + Focused, + /// The file could not be read or opened; the user was told. + Failed, +} + impl PdfCraftApp { pub fn new() -> Self { Self { @@ -1208,6 +1221,8 @@ impl PdfCraftApp { self.open_path(path); } } + #[cfg(target_arch = "wasm32")] + let _ = path; } /// The tab already showing the file at `path`, if any: its position in `views`. Paths are @@ -1222,20 +1237,25 @@ impl PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] - pub fn open_path(&mut self, path: &str) { + pub fn open_path(&mut self, path: &str) -> OpenOutcome { if let Some(i) = self.view_showing(path) { self.startup_superseded = true; self.active = Some(i); - return; + return OpenOutcome::Focused; } let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string()); match std::fs::read(path) { - Ok(bytes) => { - if let Err(e) = self.open_bytes(&name, Some(path.to_string()), bytes) { + Ok(bytes) => match self.open_bytes(&name, Some(path.to_string()), bytes) { + Ok(()) => OpenOutcome::Opened, + Err(e) => { self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + OpenOutcome::Failed } + }, + Err(e) => { + self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]); + OpenOutcome::Failed } - Err(e) => self.notify_fmt("Couldn't read {name}: {e}", &[("name", &name), ("e", &e.to_string())]), } } @@ -1437,12 +1457,12 @@ impl PdfCraftApp { let command = spec.id; self.ask_one(pickers::Ask::File(dialog), None, move |app, path| { let path = path.to_string_lossy().into_owned(); - let before = app.views.len(); - app.open_path(&path); + let outcome = app.open_path(&path); if let Some(prompt) = app.password_prompt.as_mut().filter(|p| p.path.as_deref() == Some(path.as_str())) { // Encrypted: the tool runs once the password is in. prompt.then = Some(command); - } else if app.views.len() > before && app.active.is_some() { + } else if outcome != OpenOutcome::Failed { + // A new tab or an already-open one the pick focused: either way, run on it. app.execute(command); } }); @@ -2225,7 +2245,9 @@ impl eframe::App for PdfCraftApp { for e in os_events { match e { #[cfg(not(target_arch = "wasm32"))] - OsEvent::Open(paths) => paths.iter().for_each(|p| self.open_path(p)), + OsEvent::Open(paths) => paths.iter().for_each(|p| { + self.open_path(p); + }), #[cfg(target_arch = "wasm32")] OsEvent::Open(_) => {} // Like closing the window: `guard_quit` asks about unsaved changes. diff --git a/crates/ui-egui/src/pickers.rs b/crates/ui-egui/src/pickers.rs index dcb012089..39b38d645 100644 --- a/crates/ui-egui/src/pickers.rs +++ b/crates/ui-egui/src/pickers.rs @@ -151,7 +151,9 @@ impl PdfCraftApp { }; let ask = if multiple { Ask::Files(dialog) } else { Ask::File(dialog) }; self.ask(ask, target, move |app, paths| match pick_for { - PickFor::Open => paths.iter().for_each(|p| app.open_path(&p.to_string_lossy())), + PickFor::Open => paths.iter().for_each(|p| { + app.open_path(&p.to_string_lossy()); + }), PickFor::Files(purpose) => app.use_paths(purpose, &paths), }); } diff --git a/crates/ui-egui/tests/commands.rs b/crates/ui-egui/tests/commands.rs index 48a18eb42..8674c8810 100644 --- a/crates/ui-egui/tests/commands.rs +++ b/crates/ui-egui/tests/commands.rs @@ -187,6 +187,33 @@ fn a_tool_chosen_with_no_document_asks_for_one_and_runs_on_it() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn a_tool_chosen_with_an_already_open_file_focuses_its_tab_and_still_runs() { + let dir = std::env::temp_dir().join(format!("pdfcraft-tool-focus-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("picked.pdf"); + std::fs::write(&path, fixture(2)).unwrap(); + let pick = path.to_string_lossy().into_owned(); + let picked = pick.clone(); + 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(); + // The file is already open, but the Home tab is showing (no active document). + app.open_path(&picked); + app.active = None; + app.pick_override = Some(vec![pick]); + app + }); + h.run_steps(2); + h.state_mut().run_command("export.text"); + h.run_steps(3); + let app = h.state(); + assert_eq!(app.views.len(), 1, "the existing tab is reused, not duplicated"); + assert_eq!(app.active, Some(0), "the pick focused the tab it already opened"); + assert!(matches!(app.dialog, Some(Dialog::Export(_))), "the tool still runs on it: {:?}", app.dialog); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn shortcuts_run_registered_commands() { let mut h = harness(); diff --git a/crates/ui-egui/tests/control.rs b/crates/ui-egui/tests/control.rs index 180dc85e6..7f1d27a76 100644 --- a/crates/ui-egui/tests/control.rs +++ b/crates/ui-egui/tests/control.rs @@ -1427,3 +1427,38 @@ fn read_mode_retains_existing_rail_click_to_close_policy() { assert_eq!(h.state().right, None); assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Read); } + +#[test] +fn control_open_of_an_already_open_file_replies_ok_and_focuses_its_tab() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-open-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let a = dir.join("a.pdf").to_string_lossy().into_owned(); + let b = dir.join("b.pdf").to_string_lossy().into_owned(); + std::fs::write(&a, fixture(2)).unwrap(); + std::fs::write(&b, fixture(3)).unwrap(); + let slot: Arc>> = Arc::default(); + let s = slot.clone(); + 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(); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + + let first = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(first["opened"], json!(a), "{first}"); + ok(&mut h, &c, "ui.open", json!({ "path": b })); + // The file is already open: the tab focuses, and the reply must be Ok — an agent driving the + // app headlessly would otherwise read a focus as a read failure (#1620). + let again = ok(&mut h, &c, "ui.open", json!({ "path": a })); + assert_eq!(again["focused"], true, "{again}"); + + let state = ok(&mut h, &c, "ui.state", json!({})); + let docs = state["documents"].as_array().cloned().unwrap_or_default(); + assert_eq!(docs.len(), 2, "opening an open file adds no tab: {state}"); + let a_tab = docs.iter().find(|d| d["path"] == json!(a)).unwrap(); + assert_eq!(a_tab["active"], true, "the already-open tab is in front: {state}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index 215eaedc5..9146ae65d 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -6,7 +6,7 @@ use egui::{Key, Modifiers}; use egui_kittest::Harness; use egui_kittest::kittest::{NodeT, Queryable}; use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; -use pdfcraft_ui_egui::{CloseRequest, Dialog, PdfCraftApp}; +use pdfcraft_ui_egui::{CloseRequest, Dialog, OpenOutcome, PdfCraftApp}; /// An `n`-page document with a proper xref table; page `i` shows "Page i+1". fn fixture(n: usize) -> Vec { @@ -585,7 +585,11 @@ fn the_last_session_reopens_its_files_pages_and_active_tab() { let dir = TempPdfs::new("reopen"); let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 5), dir.file("c.pdf", 2)]; let opened = files.clone(); - let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f))); + let mut h = session_harness(move |app| { + opened.iter().for_each(|f| { + app.open_path(f); + }) + }); h.state_mut().views[1].go_to_page(3); h.state_mut().active = Some(1); h.run_steps(4); @@ -607,7 +611,11 @@ fn quitting_with_unsaved_changes_still_remembers_every_file() { let dir = TempPdfs::new("quit"); let files = [dir.file("a.pdf", 1), dir.file("b.pdf", 1)]; let opened = files.clone(); - let mut h = session_harness(move |app| opened.iter().for_each(|f| app.open_path(f))); + let mut h = session_harness(move |app| { + opened.iter().for_each(|f| { + app.open_path(f); + }) + }); for tab in 0..2 { h.state_mut().active = Some(tab); h.state_mut().views[tab].select_pages(&[0]); @@ -659,17 +667,27 @@ fn opening_the_same_path_again_focuses_its_tab() { let a = pdfs.file("a.pdf", 1); let b = pdfs.file("b.pdf", 1); let mut h = session_harness(|_| {}); - h.state_mut().open_path(&a); - h.state_mut().open_path(&b); + assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Opened); + assert_eq!(h.state_mut().open_path(&b), OpenOutcome::Opened); h.run_steps(2); assert_eq!(h.state().views.len(), 2, "two different files open two tabs"); - h.state_mut().open_path(&a); + assert_eq!(h.state_mut().open_path(&a), OpenOutcome::Focused, "opening an open file focuses, it doesn't reopen"); h.run_steps(2); assert_eq!(h.state().views.len(), 2, "opening an open file again adds no tab"); assert_eq!(h.state().active, Some(0), "the existing tab comes to the front"); assert_eq!(open_paths(h.state()), vec![a, b], "the tab order is untouched"); } +#[test] +fn opening_a_missing_file_fails_and_tells_the_user() { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let missing = std::env::temp_dir().join(format!("pdfcraft-no-such-file-{}.pdf", std::process::id())); + assert_eq!(app.open_path(&missing.to_string_lossy()), OpenOutcome::Failed); + assert!(app.views.is_empty()); + assert!(app.toast.is_some(), "the failure is still reported to the user"); +} + #[test] fn opening_the_same_file_with_a_dot_component_focuses_its_tab() { let pdfs = TempPdfs::new("dedup-dot"); From 3d567bd732dc0303f0311d6aa2d93ce72ff42165 Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 16:27:41 +0200 Subject: [PATCH 07/42] fix(ui): mode tabs keep the hand over a page scrolled under them (#2755) With the All tools panel closed, page text scrolled up under the mode bar gave the Read, Edit, Convert and E-Sign tabs the page's cursor: the Select tool's I-beam, or Edit text's four-way arrow (the I-beam once a paragraph was open). The page code tested egui's raw hover position against the page's whole rectangle, which runs on under the chrome when the page is scrolled, and it sets the cursor after the mode bar does. The text layer and Edit text now set their cursor only while the pages' widget contains the pointer, as Fill & Sign already does; a selection being dragged keeps the I-beam past the edge. The open paragraph's editor, a foreground Area placed on its paragraph, also floated over the tabs and took their clicks: it is now clipped and hit-tested to the document viewport and stays on its paragraph instead of being pushed back into the window. Fixes #2755 --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/edit_text_ui.rs | 37 ++++++++---- crates/ui-egui/tests/editing.rs | 97 ++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0acab64a0..423f3bbaa 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -3148,7 +3148,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); // A link or a form field under the pointer keeps its own cursor: a check box // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - let cursor_free = !over_link && !over_field; + // So do the mode bar and side panels over a page scrolled under them (#2755), + // unless a selection is being dragged there. + let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); if column && cursor_free { ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); } else if over_text && cursor_free { diff --git a/crates/ui-egui/src/edit_text_ui.rs b/crates/ui-egui/src/edit_text_ui.rs index d8881ba47..57cdb537c 100644 --- a/crates/ui-egui/src/edit_text_ui.rs +++ b/crates/ui-egui/src/edit_text_ui.rs @@ -1081,14 +1081,18 @@ pub(crate) fn page_input( draw_handles(boxes[hit]); } // A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it - // for typing); an edge rewraps it. The open one takes the text cursor. - ui.ctx().set_cursor_icon(if on_edge { - egui::CursorIcon::ResizeHorizontal - } else if active.is_none() { - egui::CursorIcon::Move - } else { - egui::CursorIcon::Text - }); + // for typing); an edge rewraps it. The open one takes the text cursor. Only where the page + // shows, so the mode bar and side panels over a paragraph scrolled under them keep their own + // cursor (#2755). + if resp.contains_pointer() { + ui.ctx().set_cursor_icon(if on_edge { + egui::CursorIcon::ResizeHorizontal + } else if active.is_none() { + egui::CursorIcon::Move + } else { + egui::CursorIcon::Text + }); + } if resp.clicked() { view.image_selection = None; // A click that moves the editor to another paragraph applies the open one first, so its @@ -1313,6 +1317,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - .as_ref() .is_some_and(|ed| view.text_background.ready(ed.page, ed.block) && !ed.source.as_ref().is_some_and(|(b, _)| b.invisible)); let draft_rect = view.text_background.draft_rect.map(|r| xf.user_rect(info, page, r.map(|v| v as f32))); + let viewport = view.viewport_rect(); let ed = view.line_editor.as_mut()?; if hold { ed.focus = true; @@ -1332,9 +1337,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let font = editor_font(&ed.look, ed.size); let text_color = color32(ed.look.color); let mut done = None; - egui::Area::new(egui::Id::new("edit-text-line")).order(egui::Order::Foreground).fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())).show( - ctx, - |ui| { + // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or + // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as + // the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new("edit-text-line")) + .order(egui::Order::Foreground) + .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) + .constrain_to(viewport) + .constrain(false) + .show(ctx, |ui| { // The editor opens at the source size. Once text changes, show the real draft // extent: single rows grow toward the page edge, paragraphs rewrap at their // source width, and the visible frame grows/shrinks vertically with their rows. @@ -1358,6 +1369,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // On the frame, a press takes hold of the box (a click selects it, a drag moves it): // the pointer says so there, and reads as text inside. if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + && ui.clip_rect().contains(p) && live.expand(2.0 + FRAME_REACH).contains(p) && !live.expand(2.0).contains(p) { @@ -1412,8 +1424,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - done = Some(true); } }); - }, - ); + }); match done { Some(apply) => { let ed = view.line_editor.take()?; diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db52575..8177842fd 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -4673,3 +4673,100 @@ fn leaving_organize_goes_back_to_the_workspace_panel() { h.run_steps(3); assert_eq!(h.state().left, LeftPanel::AllTools); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// The cursor over each mode bar tab that page 1's text lies under, once that text is scrolled +/// up under the bar. With the All tools panel closed it lies under Read, Edit, Convert and E-Sign. +fn mode_tab_cursors(h: &mut Harness<'static, PdfCraftApp>, text: egui::Rect) -> [(&'static str, egui::CursorIcon); 4] { + h.state_mut().views[0].goto = Some((0, 0.5)); + h.run_steps(4); + let offset = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(text.translate(egui::vec2(0.0, offset)).contains(at), "the text lies under {label}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// Page 1's text block on screen, relative to the page's top edge, in the window as it is now. +fn page_text(h: &Harness<'static, PdfCraftApp>) -> egui::Rect { + let id = h.state().views[0].id; + let block = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + let at = |x: f64, y: f64| egui::pos2(r.left() + x as f32 * k, (300.0 - y as f32) * k); + egui::Rect::from_two_pos(at(block.rect[0], block.rect[1]), at(block.rect[2], block.rect[3])) +} + +/// Text scrolled up under the mode bar leaves its tabs the hand (#2755). The Select tool's +/// text layer showed its I-beam there, over chrome that hides the page. +#[test] +fn mode_tabs_keep_the_hand_over_page_text_scrolled_under_them() { + let mut h = harness(2, |app| app.set_option("left", "closed").unwrap()); + let text = page_text(&h); + // Where the page shows, its text takes the I-beam, once the page's text has arrived. + h.state_mut().views[0].goto = Some((0, 0.3)); + h.run_steps(4); + let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + let mut cursor = egui::CursorIcon::Default; + for _ in 0..50 { + h.hover_at(text.center() + egui::vec2(0.0, top)); + h.run_steps(2); + cursor = h.output().platform_output.cursor_icon; + if cursor == egui::CursorIcon::Text { + break; + } + } + assert_eq!(cursor, egui::CursorIcon::Text); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB); +} + +/// Edit text, then the All tools panel closed, as reported (#2755): a paragraph scrolled up +/// under the mode bar gave its tabs the four-way arrow, or the I-beam once a paragraph was open. +/// The open paragraph's editor also covered the tabs and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_paragraph_scrolled_under_them() { + let mut h = harness(2, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.state_mut().set_option("left", "closed").unwrap(); + h.run_steps(4); + let text = page_text(&h); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "a closed paragraph under the tabs"); + // Open it, where it shows. + h.state_mut().views[0].goto = Some((0, 0.3)); + h.run_steps(4); + let top = h.state().views[0].page_screen_rect(0).expect("on screen").top(); + click_at(&mut h, text.center() + egui::vec2(0.0, top)); + assert!(h.state().views[0].line_editor.is_some()); + assert_eq!(mode_tab_cursors(&mut h, text), HAND_ON_EVERY_TAB, "the open paragraph under the tabs"); + // Nor does the editor's frame, with the paragraph's top edge just under a tab (the frame + // reaches 2 to 6 points outside it). + let convert = mode_tab(&h, "Convert").center(); + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + h.state_mut().views[0].goto = Some((0, (page.top() - (convert.y + 4.0 - text.top())) / page.height())); + h.run_steps(4); + let page = h.state().views[0].page_screen_rect(0).expect("on screen"); + assert!((page.top() + text.top() - convert.y - 4.0).abs() < 1.0, "Convert lies on the paragraph's frame"); + h.hover_at(convert); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand, "the open paragraph's frame under a tab"); + h.state_mut().views[0].goto = Some((0, 0.5)); + h.run_steps(4); + click_at(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the paragraph's editor"); +} From f976adeb17248390699a227db1be6dd44734b41a Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Fri, 16 Oct 2026 11:20:11 -0500 Subject: [PATCH 08/42] M3: the "Save changes?" prompt opens on Save and moves between buttons by keyboard Save is chosen when the prompt opens, so Enter saves as before. The arrow keys and Tab / Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button, Escape cancels. Before, Enter always saved, even with another button reached by Tab. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 115 +++++++++++++++++++++++++++----- crates/ui-egui/src/editing.rs | 2 + crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/widgets.rs | 10 +++ crates/ui-egui/tests/editing.rs | 84 +++++++++++++++++++++++ parity/acrobat-features.toml | 4 +- 7 files changed, 203 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 259dbfec0..db5b05aa5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md | 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 | ## Progress log +- **2026-10-16 (M3, close prompt keyboard):** The "Save changes?" prompt opens with Save chosen (Enter saves, as before) and the arrow keys and Tab / Shift+Tab move the choice to Cancel or Don't save, shown with a focus ring; Enter or Space press the chosen button and Escape still cancels. Before, Enter always saved whatever was Tab-focused. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2d2407629..05209b866 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1438,21 +1438,74 @@ fn draft_changes(app: &PdfCraftApp) -> Option> { Some(edits) } -/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them: Enter -/// saves (the default button) and Escape cancels; Don't save takes ⌘D / Ctrl+D, the macOS -/// convention, or Alt+D / Alt+N, the mnemonics Windows and Linux desktops use for it. -pub(crate) fn save_prompt_key(ctx: &egui::Context) -> Option> { +/// The buttons of the "Save changes?" prompt, in the order they sit on screen, left to right. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum PromptButton { + DontSave, + Cancel, + #[default] + Save, +} + +impl PromptButton { + const ORDER: [PromptButton; 3] = [PromptButton::DontSave, PromptButton::Cancel, PromptButton::Save]; + + fn position(self) -> usize { + Self::ORDER.iter().position(|b| *b == self).unwrap_or(2) + } + + /// The button `by` places to the right (negative: left). Arrow keys stop at the ends. + fn step(self, by: isize) -> PromptButton { + let to = self.position().saturating_add_signed(by).min(Self::ORDER.len() - 1); + Self::ORDER.get(to).copied().unwrap_or(self) + } + + /// The button after (or, with `back`, before) this one, wrapping round: Tab and Shift+Tab. + fn cycle(self, back: bool) -> PromptButton { + let n = Self::ORDER.len(); + let to = if back { self.position().checked_sub(1).unwrap_or(n - 1) } else { (self.position() + 1) % n }; + Self::ORDER.get(to).copied().unwrap_or(self) + } + + /// What pressing the button answers. + fn answer(self) -> Option { + match self { + PromptButton::Save => Some(true), + PromptButton::DontSave => Some(false), + PromptButton::Cancel => None, + } + } +} + +/// The "Save changes?" prompt's keys (issue #8), read before anything else can take them. Save is +/// the button chosen when the prompt opens, so Enter saves; the arrow keys and Tab / Shift+Tab move +/// the choice, and Enter or Space press the chosen button. Escape cancels. Don't save also has +/// ⌘D / Ctrl+D, the macOS convention, and Alt+D / Alt+N, the mnemonics Windows and Linux desktops +/// use for it. Returns the answer once one is given. +pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) -> Option> { use egui::{Key, Modifiers}; ctx.input_mut(|i| { - if i.consume_key(Modifiers::NONE, Key::Enter) { - Some(Some(true)) - } else if i.consume_key(Modifiers::NONE, Key::Escape) { - Some(None) - } else if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) { - Some(Some(false)) - } else { - None + if i.consume_key(Modifiers::NONE, Key::Enter) || i.consume_key(Modifiers::NONE, Key::Space) { + return Some(chosen.answer()); } + if i.consume_key(Modifiers::NONE, Key::Escape) { + return Some(None); + } + if [(Modifiers::COMMAND, Key::D), (Modifiers::ALT, Key::D), (Modifiers::ALT, Key::N)].into_iter().any(|(m, k)| i.consume_key(m, k)) { + return Some(Some(false)); + } + // Shift+Tab before Tab: a key press matches a pattern that asks for fewer modifiers. + if i.consume_key(Modifiers::SHIFT, Key::Tab) { + *chosen = chosen.cycle(true); + } else if i.consume_key(Modifiers::NONE, Key::Tab) { + *chosen = chosen.cycle(false); + } + for (key, by) in [(Key::ArrowLeft, -1), (Key::ArrowUp, -1), (Key::ArrowRight, 1), (Key::ArrowDown, 1)] { + if i.consume_key(Modifiers::NONE, key) { + *chosen = chosen.step(by); + } + } + None }) } @@ -1492,14 +1545,15 @@ fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Your changes will be lost if you don't save them.")).color(t.text_muted)); ui.add_space(14.0); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Save"), true).clicked() { + let chosen = app.save_prompt_button; + if widgets::pill_button_marked(ui, tl!("Save"), true, chosen == PromptButton::Save).clicked() { choice = Some(Some(true)); } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + if widgets::pill_button_marked(ui, tl!("Cancel"), false, chosen == PromptButton::Cancel).clicked() { choice = Some(None); } ui.add_space(24.0); - if widgets::pill_button(ui, tl!("Don't save"), false).clicked() { + if widgets::pill_button_marked(ui, tl!("Don't save"), false, chosen == PromptButton::DontSave).clicked() { choice = Some(Some(false)); } }); @@ -1748,3 +1802,34 @@ fn unlock(app: &mut PdfCraftApp, ctx: &egui::Context) { None => {} } } + +#[cfg(test)] +mod prompt_button_tests { + use super::PromptButton::{self, Cancel, DontSave, Save}; + + #[test] + fn opens_on_save_and_arrows_stop_at_the_ends() { + assert_eq!(PromptButton::default(), Save); + assert_eq!(Save.step(1), Save); + assert_eq!(Save.step(-1), Cancel); + assert_eq!(Cancel.step(-1), DontSave); + assert_eq!(DontSave.step(-1), DontSave); + assert_eq!(DontSave.step(1000), Save, "a long step clamps"); + assert_eq!(Save.step(isize::MIN), DontSave, "and so does a huge negative one"); + } + + #[test] + fn tab_wraps_both_ways() { + assert_eq!(Save.cycle(false), DontSave); + assert_eq!(DontSave.cycle(true), Save); + assert_eq!(Cancel.cycle(false), Save); + assert_eq!(Cancel.cycle(true), DontSave); + } + + #[test] + fn each_button_answers_as_labelled() { + assert_eq!(Save.answer(), Some(true)); + assert_eq!(DontSave.answer(), Some(false)); + assert_eq!(Cancel.answer(), None); + } +} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index be1347d68..45ce08221 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -806,6 +806,8 @@ impl PdfCraftApp { /// Answer the save prompt: `Some(true)` save, `Some(false)` discard, `None` cancel. pub fn resolve_close(&mut self, ctx: &egui::Context, choice: Option) { let Some(req) = self.close_request.take() else { return }; + // The next question (another dirty document) starts on Save again. + self.save_prompt_button = Default::default(); let index = match req { CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) { Some(i) => i, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c..f0f0cf9dc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -619,6 +619,8 @@ pub struct PdfCraftApp { pub host_dirty: Option, /// A pending "save changes?" question (closing a dirty tab or quitting). pub close_request: Option, + /// The button of the "save changes?" question the keyboard has chosen; Save when it opens. + pub(crate) save_prompt_button: dialogs::PromptButton, /// A save waiting behind [`Dialog::OverwriteChanged`] (the web build has no files to check). #[cfg(not(target_arch = "wasm32"))] pub pending_overwrite: Option, @@ -962,6 +964,7 @@ impl PdfCraftApp { host_save: None, host_dirty: None, close_request: None, + save_prompt_button: Default::default(), #[cfg(not(target_arch = "wasm32"))] pending_overwrite: None, save_override: None, @@ -2771,7 +2774,7 @@ impl PdfCraftApp { // properties; Escape cancels it rather than clearing a selection), and nothing may run // underneath it. They are read here, before the canvas can consume them. if self.close_request.is_some() { - if let Some(choice) = dialogs::save_prompt_key(ctx) { + if let Some(choice) = dialogs::save_prompt_key(ctx, &mut self.save_prompt_button) { self.resolve_close(ctx, choice); } return; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4f5aaa79b..51ff47781 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -130,6 +130,12 @@ pub fn mode_tab(ui: &mut egui::Ui, label: &str, active: bool) -> Response { /// Rounded pill button; `primary` fills with the accent. pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { + pill_button_marked(ui, label, primary, false) +} + +/// [`pill_button`] that, when `marked`, is the keyboard's choice: it takes egui focus (so +/// assistive tech follows it) and shows a ring in the accent colour around the pill. +pub fn pill_button_marked(ui: &mut egui::Ui, label: &str, primary: bool, marked: bool) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(12.5); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); @@ -142,6 +148,10 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { (press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.border), t.text) }; ui.painter().rect(rect, CornerRadius::same(14), fill, stroke, egui::StrokeKind::Inside); + if marked { + resp.request_focus(); + ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(16), Stroke::new(2.0, t.accent), egui::StrokeKind::Outside); + } ui.painter().text(rect.translate(press.offset()).center(), Align2::CENTER_CENTER, label, font, text); theme::hand(resp) } diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index f8db52575..965867095 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -596,6 +596,90 @@ fn the_save_prompt_answers_to_the_keyboard() { let _ = std::fs::remove_file(out); } +/// An organize harness with a dirty document and the "Save changes?" prompt open. +fn dirty_prompt() -> Harness<'static, PdfCraftApp> { + let mut h = organize(2); + h.get_by_label("Rotate clockwise").click(); + h.run_steps(3); + h.key_press_modifiers(Modifiers::COMMAND, Key::W); + h.run_steps(3); + h.get_by_label_contains("Save changes to “doc.pdf”"); + h +} + +/// The prompt has Save chosen on arrival; `keys` then move the choice and Enter or Space presses it. +fn press_after(keys: &[(Modifiers, Key)], activate: Key) -> Harness<'static, PdfCraftApp> { + let mut h = dirty_prompt(); + for (m, k) in keys { + h.key_press_modifiers(*m, *k); + h.run_steps(2); + } + h.key_press(activate); + h.run_steps(3); + h +} + +#[test] +fn the_save_prompt_moves_between_buttons_with_the_keyboard() { + let none = Modifiers::NONE; + // Left from Save is Cancel: Enter or Space presses it, so nothing closes and nothing saves. + for activate in [Key::Enter, Key::Space] { + let h = press_after(&[(none, Key::ArrowLeft)], activate); + assert_eq!(h.state().views.len(), 1, "{activate:?} on Cancel keeps the document"); + assert!(h.state().close_request.is_none()); + assert!(dirty(&h), "{activate:?} on Cancel doesn't save"); + } + // Two left is Don't save, which closes without saving; Shift+Tab walks the same way. + for keys in [ + vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft)], + vec![(Modifiers::SHIFT, Key::Tab), (Modifiers::SHIFT, Key::Tab)], + vec![(none, Key::ArrowLeft), (none, Key::ArrowLeft), (none, Key::ArrowLeft)], + vec![(none, Key::Tab)], + ] { + let h = press_after(&keys, Key::Enter); + assert!(h.state().views.is_empty(), "{keys:?} reaches Don't save, which closes the tab"); + } + // Tab from Save wraps to Don't save; arrows stop at the right end, so Save stays chosen. + let out = temp_path("arrows-stop.pdf"); + let mut h = dirty_prompt(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press(Key::ArrowRight); + h.run_steps(2); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(h.state().views.is_empty(), "Right from Save stays on Save, which saves"); + assert!(out.exists()); + let _ = std::fs::remove_file(out); +} + +#[test] +fn the_save_prompt_escape_still_cancels_whatever_is_chosen() { + let h = press_after(&[(Modifiers::NONE, Key::ArrowLeft), (Modifiers::NONE, Key::ArrowLeft)], Key::Escape); + assert_eq!(h.state().views.len(), 1, "Escape cancels even with Don't save chosen"); + assert!(dirty(&h)); +} + +#[test] +fn the_save_prompt_starts_on_save_every_time_it_opens() { + // Move to Don't save, cancel with Escape, and ask again: the choice is back on Save. + let out = temp_path("reopened.pdf"); + let mut h = dirty_prompt(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press(Key::ArrowLeft); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + h.key_press(Key::Escape); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1); + h.key_press_modifiers(Modifiers::COMMAND, Key::W); + h.run_steps(3); + h.key_press(Key::Enter); + h.run_steps(3); + assert!(h.state().views.is_empty(), "Enter saved and closed"); + assert!(out.exists(), "the second prompt opened on Save"); + let _ = std::fs::remove_file(out); +} + #[test] fn keys_leave_the_document_under_a_dialog_alone() { // Issue #870: ⌘W with Preferences open closed the file underneath it, and in the page grid diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index c3d319f94..49ad3192a 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -586,8 +586,8 @@ milestone = "M4" status = "shipped" commands = ["file.close"] tools = ["doc_close"] -evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename"] -notes = "" +evidence = ["crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_asks_and_cancel_keeps_it", "crates/ui-egui/tests/editing.rs::closing_a_dirty_tab_can_save_first", "crates/ui-egui/tests/editing.rs::quitting_with_unsaved_changes_asks_for_each_document", "crates/ui-egui/tests/editing.rs::save_prompt_stays_inside_the_screen_for_a_long_filename", "crates/ui-egui/tests/editing.rs::the_save_prompt_moves_between_buttons_with_the_keyboard", "crates/ui-egui/tests/editing.rs::the_save_prompt_escape_still_cancels_whatever_is_chosen", "crates/ui-egui/tests/editing.rs::the_save_prompt_starts_on_save_every_time_it_opens"] +notes = "Save is chosen when the prompt opens, so Enter saves. Left/Right (and Up/Down) and Tab/Shift+Tab move the choice between Don't save, Cancel and Save, shown with a focus ring; Enter or Space press the chosen button; Escape cancels. Alt+D, Alt+N and Ctrl/Cmd+D still answer Don't save. Not yet: the same keys on the other confirmations (Revert, reload, overwrite)." [[feature]] id = "core.dirty-indicator" From 91ce75ecb1b0c333f7ab0d1ca456c6f2950cef4b Mon Sep 17 00:00:00 2001 From: Guillem Hermida Date: Fri, 16 Oct 2026 22:10:24 +0200 Subject: [PATCH 09/42] fix(ui): on-page text editors leave the mode tabs their pointer Follows up #2755, which fixed the Edit text paragraph editor. The form text field editor, the Fill & Sign text box and the Add text box are foreground Areas placed on the page too. Scrolled up under the mode bar with the All tools panel closed, they were drawn over the Read, Edit, Convert and E-Sign tabs: the tabs showed the editor's I-beam and a click on one went to the editor, so the mode didn't change. The three Areas are now constrained to the document viewport without being pushed back into it, as the paragraph editor is: they are clipped and hit-tested only where the document shows, and stay on their field or point. The form field cursor and Add text's I-beam, move and resize cursors read the raw pointer against the page's whole rectangle, which runs on under the chrome; they are now set only while the pages' widget contains the pointer, as Fill & Sign already does. --- crates/ui-egui/src/content_ui.rs | 114 ++++++++++++---------- crates/ui-egui/src/fill_sign.rs | 62 ++++++------ crates/ui-egui/src/forms_ui.rs | 155 ++++++++++++++++-------------- crates/ui-egui/tests/content.rs | 72 ++++++++++++++ crates/ui-egui/tests/fill_sign.rs | 61 ++++++++++++ crates/ui-egui/tests/forms.rs | 67 +++++++++++++ 6 files changed, 382 insertions(+), 149 deletions(-) diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 5f5ef32f7..a6ecacbe4 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -357,8 +357,13 @@ pub(crate) fn page_input( .and_then(|(_, si)| items.iter().find(|(i, _)| *i == si)) .map(|(_, a)| screen_rect(xf, info, page, a.content.rect())); let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0)); + // Cursors only where the page shows: the mode bar and side panels over a page scrolled under + // them keep their own (#2755). + let shows = resp.contains_pointer(); if adding_text && hit.is_none() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + if shows { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } if resp.clicked() { // Text that can't be drawn stays where it is being typed (#125). if cv.hold_blocked() { @@ -383,9 +388,9 @@ pub(crate) fn page_input( } return consumed; } - if let Some((_, left, top)) = corner { + if let Some((_, left, top)) = corner.filter(|_| shows) { ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); - } else if hit.is_some() { + } else if hit.is_some() && shows { ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } if resp.drag_started() { @@ -523,56 +528,63 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad let (mut commit, mut cancel, mut done) = (false, false, false); let mut undrawable = None; let memo = &view.content.undrawable; - egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).constrain(false).show(ctx, |ui| { - let Some(t) = view.content.draft.as_mut() else { return }; - ui.set_width(r.width() + 64.0); - if let Some(source_rect) = source_rect.filter(|_| !background_ready) { - ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); - } - // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); - ui.horizontal_top(|ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let resp = ui.add( - egui::TextEdit::multiline(&mut t.text) - .font(font) - .desired_width(r.width().max(60.0)) - .desired_rows(1) - .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) - .text_color(Color32::from_rgb(cr, cg, cb)) - .hint_text(tl!("Type text")) - .id_salt("added-text-edit"), - ); - if t.focus { - resp.request_focus(); - t.focus = false; + // Over the page, so only where the document shows: text scrolled under the mode bar or a side + // panel must leave them their pointer (#2755). + egui::Area::new(egui::Id::new(("added-text", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(r.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + let Some(t) = view.content.draft.as_mut() else { return }; + ui.set_width(r.width() + 64.0); + if let Some(source_rect) = source_rect.filter(|_| !background_ready) { + ui.painter().rect_filled(source_rect.union(r).expand(1.0), CornerRadius::ZERO, Color32::WHITE); + } + // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); + let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8); + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let resp = ui.add( + egui::TextEdit::multiline(&mut t.text) + .font(font) + .desired_width(r.width().max(60.0)) + .desired_rows(1) + .background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE }) + .text_color(Color32::from_rgb(cr, cg, cb)) + .hint_text(tl!("Type text")) + .id_salt("added-text-edit"), + ); + if t.focus { + resp.request_focus(); + t.focus = false; + } + let buttons = egui::Frame::NONE + .fill(tokens.card) + .stroke(Stroke::new(1.0, tokens.border)) + .corner_radius(CornerRadius::same(6)) + .inner_margin(2) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + ui.horizontal(|ui| { + done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked(); + cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked(); + }); + }) + .response + .rect; + // Pressing a button takes the focus from the text: that is not a click away. + let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); + cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); + commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; + }); + // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). + undrawable = self::undrawable(t, memo); + if let Some(c) = undrawable { + ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE)); } - let buttons = egui::Frame::NONE - .fill(tokens.card) - .stroke(Stroke::new(1.0, tokens.border)) - .corner_radius(CornerRadius::same(6)) - .inner_margin(2) - .show(ui, |ui| { - ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); - ui.horizontal(|ui| { - done = crate::icons::button(ui, "check", 24.0, false, tl!("Done adding text")).clicked(); - cancel = crate::icons::button(ui, "x", 24.0, false, tl!("Discard this text (Esc)")).clicked(); - }); - }) - .response - .rect; - // Pressing a button takes the focus from the text: that is not a click away. - let on_buttons = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| buttons.contains(p)); - cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape)); - commit = done || resp.lost_focus() && !on_buttons && !view.objects.hold_editor; }); - // Page text is drawn with the standard fonts; anything else would be saved as `?` (#125). - undrawable = self::undrawable(t, memo); - if let Some(c) = undrawable { - ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE)); - } - }); if cancel { view.content.draft = None; return (None, false); diff --git a/crates/ui-egui/src/fill_sign.rs b/crates/ui-egui/src/fill_sign.rs index 794fe0bf1..6677ab157 100644 --- a/crates/ui-egui/src/fill_sign.rs +++ b/crates/ui-egui/src/fill_sign.rs @@ -706,33 +706,41 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, let zoom = xf.rect.width() / xf.pw.max(1.0); let mut commit = false; let mut cancel = false; - egui::Area::new(egui::Id::new(("fill-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| { - let Some(t) = view.fill_text.as_mut() else { return }; - // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); - let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); - // The area's max rect is last frame's size, which caps the edit's width; widen it so the - // box grows as text is typed. - ui.set_min_width(width); - let r = ui.add( - egui::TextEdit::singleline(&mut t.text) - .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) - .desired_width(width) - .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) - .text_color(Color32::BLACK) - .hint_text(tl!("Type text")) - .id_salt("fill-text-edit"), - ); - if t.focus { - r.request_focus(); - t.focus = false; - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - cancel = true; - } else if r.lost_focus() { - commit = true; - } - }); + // Over the page, so only where the document shows: a box scrolled under the mode bar or a + // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as + // the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new(("fill-text", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(pos) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + let Some(t) = view.fill_text.as_mut() else { return }; + // The box is white in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(Color32::WHITE); + let width = ((t.text.chars().count().max(8) as f32) * t.size as f32 * 0.6 * zoom).clamp(60.0, 600.0); + // The area's max rect is last frame's size, which caps the edit's width; widen it so the + // box grows as text is typed. + ui.set_min_width(width); + let r = ui.add( + egui::TextEdit::singleline(&mut t.text) + .font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0))) + .desired_width(width) + .background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230)) + .text_color(Color32::BLACK) + .hint_text(tl!("Type text")) + .id_salt("fill-text-edit"), + ); + if t.focus { + r.request_focus(); + t.focus = false; + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel = true; + } else if r.lost_focus() { + commit = true; + } + }); if cancel { view.fill_text = None; return None; diff --git a/crates/ui-egui/src/forms_ui.rs b/crates/ui-egui/src/forms_ui.rs index 8020e868b..33c301545 100644 --- a/crates/ui-egui/src/forms_ui.rs +++ b/crates/ui-egui/src/forms_ui.rs @@ -132,11 +132,15 @@ pub(crate) fn page_input( // their action. let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some(); let usable = allowed && (fillable(f) || pressable || f.kind == FormFieldKind::Signature); - ui.ctx().set_cursor_icon(match (usable, f.kind) { - (false, _) => egui::CursorIcon::NotAllowed, - (true, FormFieldKind::Text) => egui::CursorIcon::Text, - _ => egui::CursorIcon::PointingHand, - }); + // Only where the page shows: the mode bar and side panels over a field scrolled under them + // keep their own cursor (#2755). + if resp.contains_pointer() { + ui.ctx().set_cursor_icon(match (usable, f.kind) { + (false, _) => egui::CursorIcon::NotAllowed, + (true, FormFieldKind::Text) => egui::CursorIcon::Text, + _ => egui::CursorIcon::PointingHand, + }); + } // A text field opens on the press rather than the click, so a drag that starts on it // selects its text (#2213). let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()); @@ -485,78 +489,87 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, f match f.kind { FormFieldKind::Text => { let multiline = f.has(field_flags::MULTILINE); - egui::Area::new(egui::Id::new(("form-editor", view.id.0))).order(egui::Order::Foreground).fixed_pos(rect.min).show(ctx, |ui| { - ui.set_min_size(rect.size()); - let Some(fx) = view.forms.focus.as_mut() else { return }; - // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. - let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); - // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). - ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); - let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; - te = te - // Wraps (inside the 3 px side margins) at the width the size was fitted to: - // the box less 3 pt each side, scaled. - .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) - .font(egui::FontId::proportional(font)) - .background_color(FIELD_FILL) - .text_color(Color32::BLACK) - .margin(egui::Margin::symmetric(3, 1)) - .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); - if let Some(max) = f.max_len { - te = te.char_limit(max); - } - if multiline { - te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); - } - let mut laid_out = None; - let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { - let out = te.show(ui); - laid_out = Some((out.galley, out.galley_pos)); - out.response.response - }); - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); + // Over the page, so only where the document shows: a field scrolled under the mode bar + // or a side panel must leave them their pointer (#2755). It stays on its field, cut off + // as the page is, rather than pushed back into the window. + egui::Area::new(egui::Id::new(("form-editor", view.id.0))) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .constrain_to(view.viewport_rect()) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(rect.size()); + let Some(fx) = view.forms.focus.as_mut() else { return }; + // Sized from the text being typed, so an auto-sized field shrinks to keep it in view. + let font = (field_text_size(ui.painter(), f, w, &fx.text) * zoom).clamp(3.0, 64.0); + // The field is pale yellow in every theme, so the caret must not follow the theme (pale in dark mode) (#655). + ui.visuals_mut().text_cursor.stroke.color = crate::edit_text_ui::caret_color_on(FIELD_FILL); + let mut te = if multiline { egui::TextEdit::multiline(&mut fx.text) } else { egui::TextEdit::singleline(&mut fx.text) }; + te = te + // Wraps (inside the 3 px side margins) at the width the size was fitted to: + // the box less 3 pt each side, scaled. + .desired_width((rect.width() - 6.0 * zoom + 6.0).max(1.0)) + .font(egui::FontId::proportional(font)) + .background_color(FIELD_FILL) + .text_color(Color32::BLACK) + .margin(egui::Margin::symmetric(3, 1)) + .password(f.has(field_flags::PASSWORD)) + .id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget))); + if let Some(max) = f.max_len { + te = te.char_limit(max); } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) + if multiline { + te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1)); + } + let mut laid_out = None; + let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| { + let out = te.show(ui); + laid_out = Some((out.galley, out.galley_pos)); + out.response.response + }); + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } } - } - let (enter, esc, tab, shift) = ui - .input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift)); - if esc { - close = true; - } else if tab { - next = Some(!shift); - } else if r.lost_focus() - && (!multiline || !enter) - && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) - { - if pressed { - // The field reopened with a refused value; keep it open and focused. - fx.request_focus = true; - } else { - // Enter in a single-line field, or clicking elsewhere, commits. - commit(view, form); + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } } - } - }); + let (enter, esc, tab, shift) = ui.input(|i| { + (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) + }); + if esc { + close = true; + } else if tab { + next = Some(!shift); + } else if r.lost_focus() + && (!multiline || !enter) + && !fx.calendar_rect.is_some_and(|c| ui.input(|i| i.pointer.interact_pos().is_some_and(|p| c.contains(p)))) + { + if pressed { + // The field reopened with a refused value; keep it open and focused. + fx.request_focus = true; + } else { + // Enter in a single-line field, or clicking elsewhere, commits. + commit(view, form); + } + } + }); // Date fields: a calendar under the field (Acrobat's date picker). if let pdfcraft_engine::form_scripts::Format::Date(fmt) = &f.actions.format && let Some(picked) = calendar(ctx, view, rect, fmt, today) diff --git a/crates/ui-egui/tests/content.rs b/crates/ui-egui/tests/content.rs index dd2c032ef..39c21de89 100644 --- a/crates/ui-egui/tests/content.rs +++ b/crates/ui-egui/tests/content.rs @@ -585,3 +585,75 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() { let (old, new) = ink_at(&mut h, from, to); assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)"); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// Add text, with the page scrolled up under the mode bar, leaves its tabs the hand (#2755): the +/// tool gave them its I-beam, added text its four-way arrow, and the open editor floated over +/// them and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_added_text_scrolled_under_them() { + let mut h = harness(); + assert!(h.state_mut().execute("edit.text")); + h.state_mut().set_option("left", "closed").unwrap(); + // The page across the window, so text placed at its left edge reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + assert_eq!(h.state().quick_tool, QuickTool::AddText); + // The empty page, ready for text. + assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, [10.0, 290.0, 210.0, 305.0])), HAND_ON_EVERY_TAB, "the page under the tabs"); + // Text kept by a click lower down, which starts a second box (#74). + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let p = at(&h, 10.0, 40.0); + click(&mut h, p); + h.event(egui::Event::Text("Reviewed".into())); + h.run_steps(2); + let p = at(&h, 10.0, 120.0); + click(&mut h, p); + h.event(egui::Event::Text("Approved".into())); + h.run_steps(2); + assert_eq!(texts(&h), ["Reviewed"]); + let kept = added(&h)[0].content.rect(); + assert_eq!(mode_tab_cursors(&mut h, |h| item_on_screen(h, kept)), HAND_ON_EVERY_TAB, "added text under the tabs"); + // The second box, still being typed. + let draft = |h: &Harness<'static, PdfCraftApp>| item_on_screen(h, h.state().views[0].content.draft.as_ref().expect("the open box").rect); + assert_eq!(mode_tab_cursors(&mut h, draft), HAND_ON_EVERY_TAB, "the open box under the tabs"); + let convert = mode_tab(&h, "Convert").center(); + click(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the box"); +} diff --git a/crates/ui-egui/tests/fill_sign.rs b/crates/ui-egui/tests/fill_sign.rs index 27fd6dd8a..a83cf6fa8 100644 --- a/crates/ui-egui/tests/fill_sign.rs +++ b/crates/ui-egui/tests/fill_sign.rs @@ -1515,3 +1515,64 @@ fn photos_and_images_dropped_on_the_signature_pad_are_imported_not_opened() { assert_eq!(h.state().views.len(), tabs, "nothing opened behind the dialog"); std::fs::remove_file(photo).unwrap(); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// The Fill & Sign text box scrolled up under the mode bar leaves its tabs the hand (#2755): it +/// floated over them, gave them its I-beam and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_type_box_scrolled_under_them() { + let mut h = harness(); + assert!(h.state_mut().execute("sign.fill.text")); + h.state_mut().set_option("left", "closed").unwrap(); + // The page across the window, so the box reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text)); + click(&mut h, 10.0, 360.0); + h.event(egui::Event::Text("Ada Lovelace, Countess of Lovelace".into())); + h.run_steps(3); + assert_eq!(mode_tab_cursors(&mut h, type_box_rect), HAND_ON_EVERY_TAB); + let convert = mode_tab(&h, "Convert").center(); + h.hover_at(convert); + h.run_steps(1); + h.drag_at(convert); + h.run_steps(1); + h.drop_at(convert); + h.run_steps(3); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the text box"); +} diff --git a/crates/ui-egui/tests/forms.rs b/crates/ui-egui/tests/forms.rs index 96a0a6a11..b92e6a407 100644 --- a/crates/ui-egui/tests/forms.rs +++ b/crates/ui-egui/tests/forms.rs @@ -36,6 +36,10 @@ fn click_field(h: &mut Harness<'static, PdfCraftApp>, name: &str, widget: usize) let f = doc.form.iter().find(|f| f.name == name).unwrap(); pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, widget).expect("on screen").center() }; + click_at(h, p); +} + +fn click_at(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2) { h.hover_at(p); h.run_steps(1); h.drag_at(p); @@ -1008,3 +1012,66 @@ fn auto_size_fields_shrink_and_wrap_typed_text_to_fit_their_box() { // An explicit size is kept, as in Acrobat: the text may then run past the box. assert_eq!(on_screen_fit(&h, "fixed", long).0, 10.0); } + +/// Where a mode bar tab is, by its label. +fn mode_tab(h: &Harness<'static, PdfCraftApp>, label: &str) -> egui::Rect { + use egui_kittest::kittest::Queryable; + h.get_all_by_role_and_label(egui::accesskit::Role::Button, label).map(|n| n.rect()).find(|r| r.bottom() < 120.0).expect("a mode tab") +} + +/// Scroll the page until `what` (on screen) lies across the mode bar, then the cursor over each +/// tab it lies under: with the All tools panel closed, Read, Edit, Convert and E-Sign. +fn mode_tab_cursors( + h: &mut Harness<'static, PdfCraftApp>, + what: impl Fn(&Harness<'static, PdfCraftApp>) -> egui::Rect, +) -> [(&'static str, egui::CursorIcon); 4] { + let page = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].page_screen_rect(0).expect("on screen"); + let y = what(h).center().y - page(h).top(); + let tabs = mode_tab(h, "Convert").center().y; + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + let top = page(h).top(); + h.state_mut().views[0].goto = Some((0, (top - (tabs - y)) / page(h).height())); + h.run_steps(4); + let under = what(h); + ["Read", "Edit", "Convert", "E-Sign"].map(|label| { + let at = mode_tab(h, label).center(); + assert!(under.contains(at), "{under:?} lies under {label} at {at:?}"); + h.hover_at(at); + h.run_steps(2); + (label, h.output().platform_output.cursor_icon) + }) +} + +const HAND_ON_EVERY_TAB: [(&str, egui::CursorIcon); 4] = [ + ("Read", egui::CursorIcon::PointingHand), + ("Edit", egui::CursorIcon::PointingHand), + ("Convert", egui::CursorIcon::PointingHand), + ("E-Sign", egui::CursorIcon::PointingHand), +]; + +/// A text field scrolled up under the mode bar leaves its tabs the hand (#2755). The field gave +/// them its I-beam, and its open editor floated over them and took their clicks. +#[test] +fn mode_tabs_keep_the_hand_over_a_text_field_scrolled_under_them() { + let mut h = harness(); + // The page across the window, so the name field reaches under the tabs. + h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width; + h.run_steps(4); + let name = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "name").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the field under the tabs"); + // Open it, where it shows. + h.state_mut().views[0].goto = Some((0, 0.0)); + h.run_steps(4); + click_field(&mut h, "name", 0); + assert!(h.state().views[0].forms.focus.is_some(), "the editor opened"); + assert_eq!(mode_tab_cursors(&mut h, name), HAND_ON_EVERY_TAB, "the open field under the tabs"); + let convert = mode_tab(&h, "Convert").center(); + click_at(&mut h, convert); + assert_eq!(h.state().mode, pdfcraft_ui_egui::Mode::Convert, "the tab takes the click, not the field's editor"); +} From aa5473e014cf334e833240a8257d57c6201ea4bb Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:24:02 +0200 Subject: [PATCH 10/42] fix(ui): keep a closed right panel closed when a PDF asks to open on its outline (#2884) --- crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/tests/ui.rs | 24 +++++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 79464624c..a6bedbebb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1086,7 +1086,8 @@ impl PdfCraftApp { fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) { use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len()); - if self.active == Some(index) { + // A right panel the user closed stays closed, whatever the document asks for (#2884). + if self.active == Some(index) && !self.right_panel_closed { match v.navigation { N::PageOnly => {} N::Bookmarks => self.right = Some(RightPanel::Bookmarks), diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 1b6276d7c..483827a4e 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -162,6 +162,26 @@ fn closed_side_panes_stay_closed_after_a_restart_and_opening_a_pdf() { assert!(!h.state().left_open); } +/// #2884: a right panel the user closed stays closed when a PDF asks for its outline +/// (`/PageMode /UseOutlines`), within a session and after a restart. +#[test] +fn closed_right_panel_stays_closed_for_a_document_asking_for_its_outline() { + use pdfcraft_ui_egui::RightPanel; + let pdf = || String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageMode /UseOutlines").into_bytes(); + let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens")); + assert_eq!(h.state().right, Some(RightPanel::Comments)); + h.state_mut().choose_right_panel(None); + h.state_mut().open_bytes("outline.pdf", None, pdf()).unwrap(); + h.run_steps(3); + assert_eq!(h.state().right, None, "the outline request reopened the closed right panel"); + let saved = h.state().persist(); + let h = harness(move |app| { + app.restore(&saved); + app.open_bytes("outline.pdf", None, pdf()).expect("outline PDF opens"); + }); + assert_eq!(h.state().right, None, "the closed right panel reopened after a restart"); +} + /// Read mode hides the tools, not the navigation panes: the rail's Bookmarks button opens the /// bookmarks when Read is the default workspace (#932), and the left tool pane stays hidden. #[test] @@ -184,7 +204,9 @@ fn read_mode_rail_buttons_open_their_panels() { h.run_steps(3); assert_eq!(h.state().right, None); assert!(h.query_by_label("Alpha section").is_none()); - // A document that asks for its outline (/PageMode /UseOutlines) shows it in Read mode too. + // A document that asks for its outline (/PageMode /UseOutlines) shows it in Read mode too, + // unless the user closed the panel (#2884), so forget that closing here. + h.state_mut().right_panel_closed = false; let pdf = String::from_utf8(FIXTURE.to_vec()).unwrap().replace("/Outlines 6 0 R", "/Outlines 6 0 R /PageMode /UseOutlines"); h.state_mut().open_bytes("outline.pdf", None, pdf.into_bytes()).unwrap(); h.run_steps(3); From fcdf2a21467b9acc400034b1be882c3cc3a7fba7 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 03:08:26 +0200 Subject: [PATCH 11/42] Layers panel: Reset to Initial Visibility Layer options in the Layers panel header resets every layer to the state the file's default configuration (/OCProperties /D) gives it, as Acrobat does. Visibility is view state, so the reset doesn't mark the document changed and doesn't enter Undo. doc_info now reports each layer's initial state. --- ATTRIBUTION.toml | 44 +++++++-------- Cargo.lock | 1 + crates/automation/src/lib.rs | 2 +- crates/automation/tests/automation.rs | 6 +- crates/engine/Cargo.toml | 3 + crates/engine/src/lib.rs | 16 ++++++ crates/engine/src/tests.rs | 55 ++++++++++++++++++- crates/render/src/inspect.rs | 22 +++++++- crates/ui-egui/src/i18n/ar.tsv | 2 + crates/ui-egui/src/i18n/bg.tsv | 2 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/kk.tsv | 2 + crates/ui-egui/src/i18n/lt.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sl.tsv | 2 + crates/ui-egui/src/i18n/te.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/panels.rs | 14 +++++ .../ui-egui/tests/ui_group/layer_actions.rs | 20 +++++++ docs/pdf-spec-parity.md | 2 +- parity/acrobat-features.toml | 8 +-- 34 files changed, 205 insertions(+), 32 deletions(-) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index db3dd6124..7ed20787f 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -7,7 +7,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919" +sha256 = "85100503b2b0b30b72927783a8c2a4c601f88b69c9d83a51426aaa548ecc919e" # ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences. # ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`). @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4" +sha256 = "d24435fe507f4a466a04a03e0ded2fa174cc66382d669f67cdfbdf1637b45e39" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e" +sha256 = "dba5335f7185bc07036abec5630038bd050a89402145a1a9a1636f8bcb622192" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665" +sha256 = "db22bb0649ab8922d7a6d882548037f9cd134329aee9047172ef405063e0a42a" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95" +sha256 = "86f72cce105dfad308eb67dee2a46f15319303a6ef45ddbe9f09a04c48b8577d" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8" +sha256 = "167cfb16e67622356a0563157904193d9af891dbea69a99094ff18f5ebfec7d4" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714" +sha256 = "09acccff04e4c4fe8288787eeacffa58fd63b57f3c93f512a6d7f28a62228bb5" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d" +sha256 = "61c333edf6b96cbd428616c427f5921d7bde9f780e06b759adcf609ebe9e3584" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29" +sha256 = "81147bf7c3a0e1651c862a74da86d91347ee27166a4e9817c1bf4484b2f6da33" [[asset]] path = "crates/ui-egui/src/i18n/it.tsv" @@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7" +sha256 = "3a6400e7009e4d69ac014954a169055c26b95244d5b6acef68c7023bee55ed21" [[asset]] path = "crates/ui-egui/src/i18n/pl.tsv" @@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa" +sha256 = "5c67bd8796d3de37097ee0e70baeaceaa9dbf5fba830c01237561893587c9cee" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa" +sha256 = "f7297ea2bc5bd8fc74900bda036141f71cfdebfd7d2ad60528bd3ba989a9e27e" [[asset]] path = "crates/ui-egui/src/i18n/hu.tsv" @@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c" +sha256 = "21cbdc6ba7df33318e49c6223baa3103c8b9c64c76c58ba6221b904a4a6090bc" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b" +sha256 = "85b7aa8f198de76c7045ffc4864e094717131f9256bc9fccbc4e3e44786eb254" [[asset]] path = "crates/ui-egui/src/i18n/kk.tsv" @@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017" +sha256 = "b403a9907434e35705c8062b1a0feb1eefc20c28cae3ca101f6d3877717e80a7" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba" +sha256 = "8cabeb2cf2ea5cd1d937739f33a785ab4221a3b3249e1a2b4dafe48c4ef425dd" [[asset]] path = "crates/ui-egui/src/i18n/uk.tsv" @@ -195,7 +195,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2" +sha256 = "8592c561daeb872215ef5f1615a864bfcd93a80952d84cc2a002fc6112aa8085" [[asset]] path = "crates/ui-egui/src/i18n/ar.tsv" @@ -206,7 +206,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe" +sha256 = "d1ba0e543c058d5500c47fd90df6c503b43572937d1f8245e88b6334a688fbdf" [[asset]] path = "crates/ui-egui/src/i18n/tr.tsv" @@ -217,7 +217,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927" +sha256 = "cbfcbb27e0de3a3952698d7c49c5a449602e86ad7a2c5ceb25f4f26af41a0975" [[asset]] path = "crates/ui-egui/src/i18n/lt.tsv" @@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a" +sha256 = "f4411c01d9d8df98944da540ef8bcf0d700201bd78eb3595d23862fdab210807" [[asset]] @@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45" +sha256 = "8f887d8c969cdd3e9e2dd95fa7de7b8bf683297f0edee3141fb1cdcd73243df0" [[asset]] path = "crates/ui-egui/src/i18n/sl.tsv" @@ -3363,7 +3363,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92" +sha256 = "cd7b0bd7075e4497d1e74283877d99f9ca4b9d608290ee2301aed9757ac19b83" [[asset]] diff --git a/Cargo.lock b/Cargo.lock index c6f4f49d6..f6636cabd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4131,6 +4131,7 @@ dependencies = [ "pdfcraft-redact", "pdfcraft-render", "pdfcraft-sign", + "pdfcraft-testkit", "pdfcraft-xfa", "pdfcraft-xfdf", "png 0.18.1", diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c01187306..16504bc4d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -2466,7 +2466,7 @@ fn info(d: &Document) -> Value { pdfcraft_render::LinkTarget::Other(o) => json!({ "other": o }), }, })).collect::>(), - "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible })).collect::>(), + "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. "transitions": i.transitions.iter().map(|t| json!({ diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 6a348e0d8..dec3fd7ea 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -5638,12 +5638,13 @@ fn doc_info_says_when_a_runaway_script_turned_the_forms_scripts_off() { let _ = std::fs::remove_dir_all(dir); } -/// doc_info describes a link's set-layer-visibility action by layer name. +/// doc_info describes a link's set-layer-visibility action by layer name, and each layer's +/// visibility now and as the file opens. #[test] fn doc_info_describes_set_layer_links() { let dir = workdir("layer-links"); let objs = [ - "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << >> >> >>", + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R] /D << /OFF [5 0 R] >> >> >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [6 0 R] >>", "<< /Type /OCG /Name (Red) >>", @@ -5674,6 +5675,7 @@ fn doc_info_describes_set_layer_links() { "preserve_rb": false, }) ); + assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); } mod close_argument_tests { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 74567eb48..2de1b4245 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -47,3 +47,6 @@ unicode-segmentation = "1.13" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Already in the graph through lopdf; used only for the local UTC offset ("today"). chrono = { version = "0.4", default-features = false, features = ["clock"] } + +[dev-dependencies] +pdfcraft-testkit = { workspace = true } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf689af54..7dd06f60d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -4785,6 +4785,22 @@ impl Session { true } + /// Show each layer as the file opens (Acrobat's Layers ▸ Reset to Initial Visibility). Returns + /// `true` if any layer changed; callers must drop cached rasters and text for the document, + /// as for [`Self::set_layer_visible`]. + pub fn reset_layers(&mut self, id: DocId) -> bool { + let Some(doc) = self.docs.iter_mut().find(|d| d.id == id) else { return false }; + let mut changed = false; + for l in &mut doc.info.layers { + changed |= l.visible != l.initial; + l.visible = l.initial; + } + if changed { + use_layer_choices(doc); + } + changed + } + /// Hide or show every comment on the page (Acrobat's Comments ▸ Hide all comments). Form /// fields and links still draw. Returns whether anything changed. pub fn set_hide_comments(&mut self, id: DocId, hide: bool) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d5ffac500..2f2b80977 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -685,7 +685,7 @@ fn layer_choices_survive_edits() { #[test] fn set_layer_actions_toggle_in_order_and_keep_radio_groups() { use pdfcraft_render::LayerOp::{Off, On, Toggle}; - let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible }; + let layer = |n: u32, visible: bool| Layer { id: (n, 0), name: format!("L{n}"), visible, initial: visible }; let run = |changes: &[(LayerOp, u32)], preserve_rb: bool| { // L1 and L2 are a radio-button group; L3 is on its own. let mut layers = vec![layer(1, true), layer(2, false), layer(3, true)]; @@ -761,6 +761,59 @@ fn set_layer_actions_change_what_renders() { assert!(!s.set_layer_state(DocId(999), &[(LayerOp::On, (5, 0))], true)); } +#[test] +fn reset_layers_returns_to_the_files_visibility() { + // Red (left half, on) and Green (right half, off) are a radio-button group. + let content = "/OC /R BDC 1 0 0 rg 0 0 50 100 re f EMC /OC /G BDC 0 1 0 rg 50 0 50 100 re f EMC"; + let pdf = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> >>") + .stream("", content) + .obj("<< /Type /OCG /Name (Red) >>") + .obj("<< /Type /OCG /Name (Green) >>") + .build(); + // A page drawing on layer Blue, which that file has off. + let other = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D << /OFF [5 0 R] >> >> >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + .obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /Properties << /B 5 0 R >> >> >>") + .stream("", "/OC /B BDC 0 0 1 rg 0 0 10 10 re f EMC") + .obj("<< /Type /OCG /Name (Blue) >>") + .build(); + let mut s = Session::new(); + let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap(); + let shown = |s: &Session| s.get(id).unwrap().info.layers.iter().map(|l| (l.name.clone(), l.visible)).collect::>(); + let left = |s: &Session| { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let p = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, scale: 1.0, ..Default::default() }); + let i = (50 * p.width as usize + 25) * 4; + p.rgba[i..i + 3].to_vec() + }; + assert!(s.set_layer_state(id, &[(LayerOp::On, (6, 0))], true)); + let edit = Edit::InsertPagesFrom { + name: "b.pdf".into(), + bytes: Arc::new(other), + pages: None, + at: 1, + field_conflicts: pdfcraft_organize::FieldConflicts::Rename, + }; + s.apply(id, edit).unwrap(); + let blue = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Blue").unwrap(); + assert!(s.set_layer_visible(id, blue, true)); + assert_eq!(shown(&s), [("Red".into(), false), ("Green".into(), true), ("Blue".into(), true)]); + assert_eq!(left(&s), [255, 255, 255]); + // Every layer, the inserted one too, goes back to the file's default configuration. + assert!(s.reset_layers(id)); + assert_eq!(shown(&s), [("Red".into(), true), ("Green".into(), false), ("Blue".into(), false)]); + assert_eq!(left(&s), [255, 0, 0]); + assert!(!s.reset_layers(id), "already as the file opens"); + // What the eye sees is not an edit: Undo still undoes the insert. + assert_eq!(s.get(id).unwrap().can_undo(), Some("Insert pages from b.pdf")); + assert!(!s.reset_layers(DocId(999))); +} + #[test] fn edits_to_unknown_documents_are_rejected() { let (mut s, _) = session_with(1); diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0979f3d38..a55c89fec 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -425,6 +425,8 @@ pub struct Layer { pub id: (u32, u16), pub name: String, pub visible: bool, + /// Shown as the file opens: the default configuration (`/OCProperties /D`). + pub initial: bool, } #[derive(Clone, Debug)] @@ -1428,7 +1430,7 @@ impl<'a> Inspector<'a> { _ => !base_off, }; let Some(id) = id else { continue }; - out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible }); + out.push(Layer { id, name: self.text(d, b"Name").unwrap_or_else(|| "Layer".into()), visible, initial: visible }); } } @@ -2358,6 +2360,24 @@ trailer << /Root 1 0 R /Info 4 0 R >> assert_eq!(info.layer_groups, [vec![(5, 0), (6, 0)], vec![(6, 0), (7, 0)]]); } + #[test] + fn initial_layer_visibility_is_the_default_configuration() { + // BaseState OFF: Red is listed ON; Green is listed both ON and OFF, and OFF wins; Blue + // isn't listed, so it takes the base state. + let pdf = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D << /BaseState /OFF /ON [5 0 R 6 0 R] /OFF [6 0 R] >> >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >> endobj +5 0 obj << /Type /OCG /Name (Red) >> endobj +6 0 obj << /Type /OCG /Name (Green) >> endobj +7 0 obj << /Type /OCG /Name (Blue) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let info = inspect(Arc::new(pdf.to_vec()), None).expect("opens"); + let layers: Vec<_> = info.layers.iter().map(|l| (l.name.as_str(), l.initial)).collect(); + assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); + } + const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 8697887e1..de01ec7d1 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -1332,6 +1332,8 @@ This document has no layers. هذا المستند لا يحتوي على طبقات. This document has no attachments. هذا المستند لا يحتوي على مرفقات. Hide layer إخفاء الطبقة + Layer options خيارات الطبقات + Reset to Initial Visibility إعادة تعيين إلى الرؤية الأولية Show layer إظهار الطبقة Set layer visibility ضبط ظهور الطبقة on page {label} في الصفحة {label} diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 813a2c7ae..6f910e29e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1274,6 +1274,8 @@ This document has no layers. Този документ няма слоеве. This document has no attachments. Този документ няма прикачени файлове. Hide layer Скрий слоя + Layer options Опции за слоеве + Reset to Initial Visibility Възстанови първоначалната видимост Show layer Покажи слоя Set layer visibility Задай видимост на слоя on page {label} на страница {label} diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 993632552..e1af800e1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -1400,6 +1400,8 @@ key Space Mezerník This document has no layers. Tento dokument nemá žádné vrstvy. This document has no attachments. Tento dokument nemá žádné přílohy. Hide layer Skrýt vrstvu + Layer options Možnosti vrstev + Reset to Initial Visibility Obnovit počáteční viditelnost Show layer Zobrazit vrstvu Set layer visibility Nastavit viditelnost vrstvy on page {label} na stránce {label} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4d9207eb9..70bca4903 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1318,6 +1318,8 @@ This document has no layers. Dieses Dokument hat keine Ebenen. This document has no attachments. Dieses Dokument hat keine Anhänge. Hide layer Ebene ausblenden + Layer options Ebenenoptionen + Reset to Initial Visibility Ursprüngliche Sichtbarkeit wiederherstellen Show layer Ebene einblenden on page {label} auf Seite {label} Save attachment… Anhang speichern… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 06fe2b540..888732aeb 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -363,6 +363,8 @@ Distribute Κατανομή Show layer Εμφάνιση επιπέδου Hide layer Απόκρυψη επιπέδου + Layer options Επιλογές επιπέδων + Reset to Initial Visibility Επαναφορά αρχικής ορατότητας Collate Συρραφή αντιγράφων Fit to page Προσαρμογή στη σελίδα Reverse pages Αντιστροφή σειράς σελίδων diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 79c668992..29105aff1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1327,6 +1327,8 @@ This document has no layers. Este documento no tiene capas. This document has no attachments. Este documento no tiene archivos adjuntos. Hide layer Ocultar capa + Layer options Opciones de capas + Reset to Initial Visibility Restablecer visibilidad inicial Show layer Mostrar capa Set layer visibility Definir visibilidad de capas on page {label} en la página {label} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 9bdcf0bc8..224746bad 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1313,6 +1313,8 @@ This document has no layers. Ce document ne contient aucun calque. This document has no attachments. Ce document ne contient aucune pièce jointe. Hide layer Masquer le calque + Layer options Options des calques + Reset to Initial Visibility Rétablir la visibilité initiale Show layer Afficher le calque on page {label} à la page {label} Save attachment… Enregistrer la pièce jointe… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index d537677c7..821e991f1 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1303,6 +1303,8 @@ This document has no layers. Ebben a dokumentumban nincs réteg. This document has no attachments. Ebben a dokumentumban nincs melléklet. Hide layer Réteg elrejtése + Layer options Réteg beállításai + Reset to Initial Visibility Kezdeti láthatóság visszaállítása Show layer Réteg megjelenítése on page {label} {label}. oldal Save attachment… Melléklet mentése… diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 35cba7a41..6af7e04e1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1328,6 +1328,8 @@ This document has no layers. Questo documento non ha livelli. This document has no attachments. Questo documento non ha allegati. Hide layer Nascondi livello + Layer options Opzioni livelli + Reset to Initial Visibility Ripristina visibilità iniziale Show layer Mostra livello Set layer visibility Imposta visibilità livello on page {label} a pagina {label} diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index e845a736d..973714c15 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1333,6 +1333,8 @@ This document has no layers. この文書にレイヤーはありません。 This document has no attachments. この文書に添付ファイルはありません。 Hide layer レイヤーを非表示 + Layer options レイヤーのオプション + Reset to Initial Visibility 初期表示状態にリセット Show layer レイヤーを表示 Set layer visibility レイヤーの表示を設定 on page {label} ページ {label} diff --git a/crates/ui-egui/src/i18n/kk.tsv b/crates/ui-egui/src/i18n/kk.tsv index 9acb4ec1b..541968d64 100644 --- a/crates/ui-egui/src/i18n/kk.tsv +++ b/crates/ui-egui/src/i18n/kk.tsv @@ -1275,6 +1275,8 @@ This document has no layers. Бұл құжатта қабаттар жоқ. This document has no attachments. Бұл құжатта тіркемелер жоқ. Hide layer Қабатты жасыру + Layer options Қабат параметрлері + Reset to Initial Visibility Бастапқы көрінуді қалпына келтіру Show layer Қабатты көрсету Set layer visibility Қабаттың көрінісін орнату on page {label} {label} бетінде diff --git a/crates/ui-egui/src/i18n/lt.tsv b/crates/ui-egui/src/i18n/lt.tsv index d1dfc3304..dd95e237b 100644 --- a/crates/ui-egui/src/i18n/lt.tsv +++ b/crates/ui-egui/src/i18n/lt.tsv @@ -629,6 +629,8 @@ Height: Aukštis: Hide field highlights Slėpti laukų paryškinimą Hide layer Slėpti sluoksnį + Layer options Sluoksnių parinktys + Reset to Initial Visibility Atkurti pradinį matomumą Hide menu bar Slėpti meniu juostą Hide password Slėpti slaptažodį Hide toolbars Slėpti įrankių juostas diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c432a6a92..dfc1e2feb 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -1318,6 +1318,8 @@ This document has no layers. Ten dokument nie zawiera warstw. This document has no attachments. Ten dokument nie zawiera załączników. Hide layer Ukryj warstwę + Layer options Opcje warstw + Reset to Initial Visibility Przywróć początkową widoczność Show layer Pokaż warstwę Set layer visibility Ustaw widoczność warstwy on page {label} na stronie {label} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 89ff80f9d..38c0df51c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1218,6 +1218,8 @@ This document has no layers. Este documento não tem camadas. This document has no attachments. Este documento não tem anexos. Hide layer Ocultar camada + Layer options Opções de camadas + Reset to Initial Visibility Redefinir visibilidade inicial Show layer Mostrar camada on page {label} na página {label} Save attachment… Salvar anexo… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 73f21aa0d..cb34b11e9 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1333,6 +1333,8 @@ This document has no layers. В этом документе нет слоёв. This document has no attachments. В этом документе нет вложений. Hide layer Скрыть слой + Layer options Параметры слоёв + Reset to Initial Visibility Восстановить исходную видимость Show layer Показать слой Set layer visibility Задать видимость слоя on page {label} на стр. {label} diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index dd169bc96..8c4b3a20a 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -902,6 +902,8 @@ Hide all comments Skrij vse komentarje Hide field highlights Skrij osvetlitev polj Hide layer Skrij plast + Layer options Možnosti plasti + Reset to Initial Visibility Ponastavi na začetno vidnost Hide menu bar Skrij menijsko vrstico Hide password Skrij geslo Hide toolbars Skrij orodne vrstice diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 47aee36f8..fe586e988 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -1303,6 +1303,8 @@ signature pad Type టైప్ చేయి This document has no layers. ఈ పత్రంలో లేయర్‌లు లేవు. This document has no attachments. ఈ పత్రంలో జోడింపులు లేవు. Hide layer లేయర్‌ను దాచు + Layer options లేయర్ ఎంపికలు + Reset to Initial Visibility ప్రారంభ దృశ్యతకు రీసెట్ చేయి Show layer లేయర్‌ను చూపించు on page {label} పేజీ {label}లో Save attachment… జోడింపును సేవ్ చేయి… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 95ca0b608..ebed0ff7f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1290,6 +1290,8 @@ This document has no layers. Bu belgede katman yok. This document has no attachments. Bu belgede ek yok. Hide layer Katmanı gizle + Layer options Katman seçenekleri + Reset to Initial Visibility Başlangıç görünürlüğüne sıfırla Show layer Katmanı göster on page {label} sayfa {label} üzerinde Save attachment… Eki kaydet… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 7993a0618..444387a8d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1330,6 +1330,8 @@ This document has no layers. Цей документ не має шарів. This document has no attachments. Цей документ не має вкладень. Hide layer Приховати шар + Layer options Параметри шарів + Reset to Initial Visibility Відновити початкову видимість Show layer Показати шар Set layer visibility Задати видимість шару on page {label} на сторінці {label} diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index bae599bf6..a00fc8648 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -625,6 +625,8 @@ theme Appearance Giao diện Height: Chiều cao: Hide field highlights Ẩn tô sáng trường Hide layer Ẩn lớp + Layer options Tùy chọn lớp + Reset to Initial Visibility Đặt lại khả năng hiển thị ban đầu Hide menu bar Ẩn thanh trình đơn Hide password Ẩn mật khẩu Hide toolbars Ẩn thanh công cụ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1ddecc655..29ba69b37 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1281,6 +1281,8 @@ This document has no layers. 本文档没有图层。 This document has no attachments. 本文档没有附件。 Hide layer 隐藏图层 + Layer options 图层选项 + Reset to Initial Visibility 重置为初始可见性 Show layer 显示图层 Set layer visibility 设置图层可见性 on page {label} 第 {label} 页 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bb5ec3649..5273bf121 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1225,6 +1225,8 @@ This document has no layers. 本文件沒有圖層。 This document has no attachments. 本文件沒有附件。 Hide layer 隱藏圖層 + Layer options 圖層選項 + Reset to Initial Visibility 重設為初始可見性 Show layer 顯示圖層 Set layer visibility 設定圖層可見性 on page {label} 第 {label} 頁 diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 0f2c60149..64589195c 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -507,6 +507,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut nav: Option