From d292b85d5f62d2597bfa5c9ce1d474c8f8dfe7a8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 31 Oct 2026 13:32:19 +0100 Subject: [PATCH 1/2] M3: reveal recent files from the Home context menu (#5452) Co-Authored-By: Codex --- ROADMAP.md | 3 +++ contributors/people.toml | 3 +++ crates/ui-egui/src/home.rs | 14 ++++++++++ crates/ui-egui/src/menus/tab_context.rs | 14 +++++++--- crates/ui-egui/tests/it/ui.rs | 35 +++++++++++++++++++++++++ docs/roadmap.md | 5 +++- parity/acrobat-features.toml | 4 +-- 7 files changed, 71 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 56f6237e8d..96c1f0e1a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,6 @@ # PdfCraft roadmap +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3, #5452: native Recent rows gain Reveal in Finder / File Explorer; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -213,6 +214,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **2026-10-31 (M3, #5452):** Native Home Recent rows offer Reveal in Finder / File Explorer from their context menu. They reuse the tab action’s platform dispatch and missing-file notification without opening the PDF or changing Recent. Browser behavior and milestone estimates unchanged. + - **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged. - **2026-10-31 (M2, PR #4172):** Integrated main fa5f9f8c, retaining both roadmap histories and raster regression groups. Restored display_config and thumbnail_config imports after the upstream explicit-import migration. Validation: 422 renderer unit tests, engine/UI all-target checks, scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Existing optional-patch/WASM warnings and parity notes remain; no full engine/UI runtime test rerun. Display-only font policy and estimates unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index a5a47f5c52..7ad98e88db 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -180,3 +180,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.vangel76] +sync_github_name = true diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 390b059228..39b43226cb 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -156,9 +156,19 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } let mut open = None; let mut remove = None; + #[cfg(not(target_arch = "wasm32"))] + let mut reveal = None; for r in &app.recent { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); + #[cfg(not(target_arch = "wasm32"))] + resp.context_menu(|ui| { + let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; + if ui.button(label).clicked() { + reveal = Some(r.path.clone()); + ui.close(); + } + }); let press = theme::Press::track(ui, &resp); // The row, not its response: the pointer stays "in" the row over its remove button. let hovered = ui.rect_contains_pointer(rect); @@ -209,6 +219,10 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if let Some(p) = open { app.open_recent(&p); } + #[cfg(not(target_arch = "wasm32"))] + if let Some(p) = reveal { + app.reveal_path(std::path::Path::new(&p)); + } if let Some(p) = remove { app.recent.retain(|r| r.path != p); } diff --git a/crates/ui-egui/src/menus/tab_context.rs b/crates/ui-egui/src/menus/tab_context.rs index 9590969baf..6f8298d7b6 100644 --- a/crates/ui-egui/src/menus/tab_context.rs +++ b/crates/ui-egui/src/menus/tab_context.rs @@ -8,6 +8,14 @@ pub(crate) enum Action { } impl crate::PdfCraftApp { + /// Reveal a local file without opening it or changing document/Recent state. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn reveal_path(&mut self, path: &std::path::Path) { + if let Err(e) = reveal(path) { + self.notify_error(e); + } + } + pub(crate) fn tab_action(&mut self, action: Action) { match action { Action::Close(id) => { @@ -35,10 +43,8 @@ impl crate::PdfCraftApp { } Action::Reveal(id) => { #[cfg(not(target_arch = "wasm32"))] - if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) - && let Err(e) = reveal(std::path::Path::new(&path)) - { - self.notify_error(e); + if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { + self.reveal_path(std::path::Path::new(&path)); } #[cfg(target_arch = "wasm32")] let _ = id; diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index 69be82e7a7..62677c4459 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1865,3 +1865,38 @@ fn full_screen_neither_is_remembered_nor_hides_the_last_view() { view.leave_presentation(); assert_eq!((view.fit, view.zoom, view.layout, view.continuous), (Fit::None, 0.5, PageLayout::TwoUp, false), "the remembered view"); } + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn home_recent_context_menu_reveals_the_target_without_opening_or_removing_it() { + let missing = std::env::temp_dir().join(format!("pdfcraft-recent-missing-{}", std::process::id())).join("jährlicher report.pdf"); + assert!(!missing.exists()); + let expected = missing.to_string_lossy().into_owned(); + let target = expected.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(move |_| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + for (name, path) in [("first.pdf", ".".to_owned()), ("target.pdf", target)] { + app.recent.push(pdfcraft_ui_egui::RecentFile { name: name.into(), path, pages: 1, size: 0 }); + } + app + }); + h.run_steps(4); + let label = if cfg!(target_os = "macos") { "Reveal in Finder" } else { "Reveal in File Explorer" }; + h.get_by_label("target.pdf").click_secondary(); + h.run_steps(3); + assert!(!h.get_by_label(label).accesskit_node().is_disabled()); + assert!(h.state().views.is_empty(), "right-click must not open the PDF"); + assert_eq!(h.state().recent.len(), 2); + h.get_by_label(label).click(); + h.run_steps(3); + assert!(h.query_by_label(label).is_none(), "choosing the action closes its menu"); + assert!(h.state().views.is_empty(), "reveal must not open the PDF"); + assert_eq!(h.state().active, None); + assert_eq!(h.state().recent.len(), 2, "reveal must not remove missing recent files"); + assert_eq!(h.state().recent[1].path, expected); + assert!( + h.state().toast.as_ref().is_some_and(|(text, _)| text.contains("The file no longer exists")), + "the selected path is checked and the existing reveal error is shown" + ); +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 0f1fb6f4d5..f04798249c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** minor (M3, #5452: native Recent rows gain Reveal in Finder / File Explorer; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged) > **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged) > **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged) @@ -194,7 +195,7 @@ total matches the parity estimate. | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. Forms without Resources use page Properties for ActualText and layer visibility; own dictionaries stop fallback. Oversized missing inline-image samples, including header and terminator overhead, no longer pin the content window (#3107 / PR #3438); normal large streamed images retain deterministic sample boundaries. Radial luminosity fades have synthetic coverage for the existing #2132 mask guard, including per-pixel references, antialiased clips, previews, scales and tiles; no additional production fix was needed. Dotted TOC leaders no longer inflate letter tracking or hide word spaces (#3086); phrase search and selection are covered. Short tracked ellipses retain their gap samples and remain searchable. Missing spacing glyphs in non-embedded Japanese CID substitutes render blank without changing PDF advances or Unicode (#3601). The desktop viewer can use installed CJK fonts, including Windows GBK Song/Hei/Kai/FangSong and subset-prefixed Founder names; Combine thumbnails and accessibility figure previews use the same display-only fallback, without changing export or print font policy (#234, continuation of #1567, review #4172). Print previews exclude installed faces and isolate their font cache; GBK regressions cover view/print switching for inline and pooled whole-page/tile rendering. Installed-font helpers and their regression contracts have function documentation. GBK regression rasters match explicit-Unicode references with independently selected installed faces; wrong-glyph and wrong-family negative controls fail. Integration with main fa5f9f8c retains viewer fallback across the split engine session modules, renderer tests and Combine thumbnail workers. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. | | M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings | -| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. | +| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). #3808: the Pages inspector offers a 50–150% thumbnail slider and buttons, also available through `ui.set`. Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. #3149: page-list changes discard render demand based on old geometry; actual raster dimensions are checked against the GPU limit before upload. Synthetic crash and continued-rendering regressions cover the replacement path. Save-prompt shortcuts yield to dialogs above them; stacked link-consent UI regression covers Escape and Return. Comment hover follows individual marked lines and original annotation paint order (#3541/#3620), independently of spatial inspection sorting; synthetic rotation, differing-upper-edge overlap and unchanged-document regressions. Optional page gaps join page edges without cropping PDF margins; current-document toggle, persisted default and UI control options are covered by viewer tests. Gap changes in Organize Pages preserve document scroll position, and both comparison panes follow menu and control choices. Repeated window resizes by an edge and moves by the tab strip on GNOME/Wayland work without a click in between. Native Recent rows also offer Reveal in Finder / File Explorer (#5452). | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. | | M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel
Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. | @@ -274,6 +275,8 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st ## Revision history +**2026-10-31 · minor:** M3/#5452: native Home Recent context menus reuse tab reveal, retaining missing-file errors and leaving documents and history unchanged. Estimates unchanged. + **2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged. **2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..5e34c20a3d 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -682,8 +682,8 @@ milestone = "M3" status = "shipped" commands = ["file.open_recent", "file.clear_recent"] tools = [] -evidence = ["crates/ui-egui/tests/it/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/it/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, 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)" +evidence = ["crates/ui-egui/tests/it/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/it/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/it/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/it/ui.rs::home_removes_one_recent_file_or_clears_them_all", "crates/ui-egui/tests/it/ui.rs::home_recent_context_menu_reveals_the_target_without_opening_or_removing_it"] +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); native Home rows offer Reveal in Finder / File Explorer through their context menu (#5452), reusing tab reveal without opening or removing the file (browser has no local file manager); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)" [[feature]] id = "core.reopen-last-session" From 0ac9f72d95ca4ec9a21bd6b91bb83171d87814b2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 16:53:18 +0100 Subject: [PATCH 2/2] home: reveal from the keyboard, and run Reveal off the UI thread The Menu key or Shift+F10 on a focused Recent row opens the Reveal menu. The existence check and file-manager call now run on a worker on every platform; a failure comes back to the UI thread and is shown as a notification. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/app.rs | 3 ++ crates/ui-egui/src/home.rs | 39 ++++++++++++++++--- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menus/tab_context.rs | 50 +++++++++++++++++++------ 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 04dbd19447..7962321060 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -152,6 +152,8 @@ impl PdfCraftApp { open_prepare_gate: None, pinned: Default::default(), recent_check: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + reveal_errors: Default::default(), pinned_tools: Vec::new(), pin_tool_search: String::new(), toast: None, @@ -468,6 +470,7 @@ impl eframe::App for PdfCraftApp { worker.set_context(ctx); } self.poll_pending_opens(); + self.show_reveal_errors(); } self.check_restored_recent(); // Chinese wants its own faces before the Japanese ones (one baseline per line). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index d229402a16..817655b3ca 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -213,13 +213,23 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name)); #[cfg(not(target_arch = "wasm32"))] - resp.context_menu(|ui| { - let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; - if ui.button(label).clicked() { - reveal = Some(r.path.clone()); - ui.close(); + { + // The Menu key or Shift+F10 on the focused row opens the same menu a secondary + // click does, under the row, so Reveal works from the keyboard. + let by_key = + resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Menu) || (i.modifiers.shift && i.key_pressed(egui::Key::F10))); + let mut popup = egui::Popup::context_menu(&resp); + if by_key { + popup = popup.open_memory(egui::SetOpenCommand::Bool(true)).anchor(rect).align(egui::RectAlign::BOTTOM_START); } - }); + popup.show(|ui| { + let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; + if ui.button(label).clicked() { + reveal = Some(r.path.clone()); + ui.close(); + } + }); + } let press = theme::Press::track(ui, &resp); // The row, not its response: the pointer stays "in" the row over its remove button. let hovered = ui.rect_contains_pointer(rect); @@ -329,6 +339,23 @@ mod tests { assert!(h.state().pinned_tools.is_empty()); } + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn a_focused_recent_row_opens_its_reveal_menu_from_the_keyboard() { + use egui_kittest::kittest::Queryable; + for chord in [(egui::Modifiers::NONE, egui::Key::Menu), (egui::Modifiers::SHIFT, egui::Key::F10)] { + let mut h = review_home_harness(); + h.state_mut().recent = vec![crate::RecentFile { name: "notes.pdf".into(), path: "/nowhere/notes.pdf".into(), pages: 2, size: 2048 }]; + h.run_steps(3); + assert!(!egui::Popup::is_any_open(&h.ctx)); + h.get_by_label("notes.pdf").focus(); + h.run_steps(2); + h.key_press_modifiers(chord.0, chord.1); + h.run_steps(3); + assert!(egui::Popup::is_any_open(&h.ctx), "{chord:?} did not open the Reveal menu"); + } + } + #[test] fn translated_card_description_leaves_room_for_the_action() { if !crate::i18n::needs_catalogs() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 380f84a644..ac3bc98704 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -416,6 +416,9 @@ pub struct PdfCraftApp { pub pinned: folders_ui::PinnedFolders, /// The recent files restored from settings, checked off the UI thread (#5682). recent_check: persist::recent_check::RecentCheck, + /// Failures of the Reveal worker, taken each frame and shown as a notification. + #[cfg(not(target_arch = "wasm32"))] + reveal_errors: std::sync::Arc>>, /// Tools pinned to Home, in user-defined order. pub pinned_tools: Vec, /// Search query for the Pin a tool selection dialog. diff --git a/crates/ui-egui/src/menus/tab_context.rs b/crates/ui-egui/src/menus/tab_context.rs index 6f8298d7b6..b1db76a4e4 100644 --- a/crates/ui-egui/src/menus/tab_context.rs +++ b/crates/ui-egui/src/menus/tab_context.rs @@ -8,10 +8,35 @@ pub(crate) enum Action { } impl crate::PdfCraftApp { - /// Reveal a local file without opening it or changing document/Recent state. + /// Reveal a local file without opening it or changing document/Recent state. The existence + /// check and the file manager call can block (a disconnected network mount, a slow `dbus-send` + /// or `explorer.exe`), so they run on a worker; a failure is shown by + /// [`Self::show_reveal_errors`]. Tests run it inline ([`PdfCraftApp::run_inline`]). #[cfg(not(target_arch = "wasm32"))] pub(crate) fn reveal_path(&mut self, path: &std::path::Path) { - if let Err(e) = reveal(path) { + let path = path.to_path_buf(); + let errors = self.reveal_errors.clone(); + let ctx = self.ctx.clone(); + let work = move || { + if let Err(e) = reveal(&path) { + errors.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(e); + if let Some(ctx) = &ctx { + ctx.request_repaint(); + } + } + }; + if self.run_inline { + work(); + } else if let Err(e) = std::thread::Builder::new().name("pdfcraft-reveal".into()).spawn(work) { + self.notify_error(e); + } + } + + /// Show the failures the reveal worker has reported (called each frame). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn show_reveal_errors(&mut self) { + let errors = std::mem::take(&mut *self.reveal_errors.lock().unwrap_or_else(std::sync::PoisonError::into_inner)); + for e in errors { self.notify_error(e); } } @@ -54,6 +79,7 @@ impl crate::PdfCraftApp { } #[cfg(not(target_arch = "wasm32"))] +/// Blocking: call it from a worker thread ([`crate::PdfCraftApp::reveal_path`]). fn reveal(path: &std::path::Path) -> Result<(), String> { // Absolute paths also prevent a filename starting with '-' becoming an option. let path = std::path::absolute(path).map_err(|e| e.to_string())?; @@ -63,16 +89,6 @@ fn reveal(path: &std::path::Path) -> Result<(), String> { let platform = std::env::consts::OS; let run = |program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string()); - if matches!(platform, "linux" | "freebsd") { - // `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run), - // so it runs off the UI thread; a failure there is only logged. - let spawned = std::thread::Builder::new().name("pdfcraft-reveal".into()).spawn(move || { - if let Err(e) = reveal_via(platform, &path, run) { - log::warn!("reveal {}: {e}", path.display()); - } - }); - return spawned.map(|_| ()).map_err(|e| e.to_string()); - } reveal_via(platform, &path, run) } @@ -236,6 +252,16 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn a_failed_reveal_is_reported_back_to_the_ui_thread() { + let mut app = crate::PdfCraftApp::new(); + app.run_inline = true; + app.reveal_path(std::path::Path::new("/pdfcraft-no-such-dir/missing.pdf")); + assert!(app.toast.is_none(), "nothing is shown until the UI thread takes the report"); + app.show_reveal_errors(); + assert_eq!(app.toast.as_ref().map(|t| t.0.as_str()), Some("The file no longer exists")); + } + #[test] fn unavailable_file_manager_falls_back_safely_and_reports_failure() { let path = std::path::Path::new("/tmp/年度 & reports/file.pdf");