From 1e2e7cc79fcda2358e1c5809502faf9e55437cc8 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:39:18 +0800 Subject: [PATCH] M3: full screen shows one fitted page at a time, as Acrobat's Full Screen Mode (#1459) Full screen is the page on black and nothing else: one page (or one spread, when two-page view was on, #1353) fitted to the screen, from the current page; a click, Space, Enter, the arrows and Page Up/Down turn pages, a shift-click and Backspace go back; Esc brings the display from before back at the page reached. The quick tools are not drawn in full screen. Transitions, auto-advance, loop and the cursor hiding stay planned. Refs #379. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz --- ROADMAP.md | 1 + crates/ui-egui/src/canvas.rs | 81 ++++++++++++++++++++++++++++++++- crates/ui-egui/src/commands.rs | 2 +- crates/ui-egui/src/create_ui.rs | 6 ++- crates/ui-egui/src/files.rs | 6 ++- crates/ui-egui/src/lib.rs | 43 +++++++++++------ crates/ui-egui/tests/view.rs | 79 ++++++++++++++++++++++++++++++++ docs/ui-parity.md | 5 +- parity/acrobat-features.toml | 12 ++--- 9 files changed, 208 insertions(+), 27 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c6bcf08c3..3531a82b3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -249,6 +249,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M3, control-click candidate):** Coordinate clicks retain hover warmup and pair press/release in one input pass so slow frames do not turn a click into a hold. Text multi-click selection accepts the actual press event when egui has already cleared its origin. Regression cases cover slow-frame button activation, disabled controls and coalesced text selection; estimates unchanged. - **2026-10-14 (community, M7, #128 Hebrew in Add text):** Add text and `page_add_text` draw Hebrew with the craft-fonts Hebr face (Noto Sans Hebrew), through the same shaped path as Arabic and Thai: right-to-left display order with numbers and Latin in place, points and cantillation kept on their letter as one Type 3 glyph, ToUnicode and per-line ActualText so extraction, search and the editor get typing order. Builds without the face refuse Hebrew rather than write `?`. Regular style only. Estimates unchanged. - **2026-10-14 (M9, #1787):** Changes after signing are judged by the role an object held when signed for a page's resources too: a rewritten font, XObject, graphics state or resources dictionary is page content however its new version is labelled, so a `/FT` or `/Subtype /Widget` added by the attacker no longer reads as a permitted form fill. A category dictionary that only gained a name is judged as before. +- **2026-10-14 (M3, full screen as presentation, #379/#1459/#1353):** Full Screen Mode shows one page (or one spread) at a time fitted to the screen on black, with no tools; click, Space, Enter and the arrows turn pages; Esc restores the display from before. Transitions, auto-advance and loop remain planned. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 16ae63490..eb539d985 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -81,6 +81,17 @@ pub enum Fit { None, } +/// The page display as it was before full screen, put back on leaving it +/// ([`DocView::enter_presentation`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SavedDisplay { + pub layout: PageLayout, + pub continuous: bool, + pub cover: bool, + pub fit: Fit, + pub zoom: f32, +} + /// The base page display: one page across, or a two-page spread. Whether it scrolls continuously /// is a separate flag (`continuous`), as in Acrobat, so every combination is reachable (#752). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -414,6 +425,9 @@ pub struct DocView { pub flash: Option<(usize, [f32; 4], f64)>, /// Compare files: differences shaded on this document's pages (page, user-space box, colour). pub compare_marks: Vec<(usize, [f32; 4], Color32)>, + /// Full screen (Acrobat's Full Screen Mode): the display saved on entering it, while the view + /// shows one page or spread at a time, fitted to the screen. `None` outside full screen. + presentation: Option, pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, @@ -723,6 +737,7 @@ impl DocView { single_edges: None, flash: None, compare_marks: Vec::new(), + presentation: None, pages: HashMap::new(), errors: HashMap::new(), waiting_since: HashMap::new(), @@ -1372,6 +1387,37 @@ impl DocView { self.reset_coarse_state(); } + /// Full screen, as Acrobat's Full Screen Mode (#379, #1459): one page at a time (or one + /// spread, when two-page view was on: #1353), fitted to the screen, from the current page. + /// The display it replaces is kept for [`Self::leave_presentation`]. A no-op while presenting. + pub fn enter_presentation(&mut self) { + if self.presentation.is_some() { + return; + } + self.presentation = + Some(SavedDisplay { layout: self.layout, continuous: self.continuous, cover: self.cover, fit: self.fit, zoom: self.zoom }); + self.continuous = false; + self.fit = Fit::Page; + self.selection = None; + self.goto = Some((self.current, 0.0)); + } + + /// Leave full screen: the display from before it comes back, at the page reached meanwhile. + pub fn leave_presentation(&mut self) { + let Some(saved) = self.presentation.take() else { return }; + self.layout = saved.layout; + self.continuous = saved.continuous; + self.cover = saved.cover; + self.fit = saved.fit; + self.zoom = saved.zoom; + self.goto = Some((self.current, 0.0)); + } + + /// Whether the view is in full screen's one-page-at-a-time display. + pub fn presenting(&self) -> bool { + self.presentation.is_some() + } + /// Switch the base page display (Single or Two-page), leaving continuous scrolling as it is. /// A no-op when the layout is already current, so re-selecting it never yanks a scrolled view /// back to the top. @@ -2300,6 +2346,16 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if key(Key::End) { view.go_to_page(usize::MAX); } + // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the + // arrows and Page Up / Page Down already turn pages where nothing scrolls, below). + if view.presentation.is_some() { + if key(Key::Space) || key(Key::Enter) { + view.step_page(true); + } + if key(Key::Backspace) { + view.step_page(false); + } + } // As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do // ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. let command = ctx.input(|i| i.modifiers.command); @@ -2469,6 +2525,22 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Exclude the previous frame's scrollbar tracks from gesture activation. let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + // Full screen: a click on the document goes forward a page and a shift-click back, as in + // Acrobat, unless the click did something else (a link, a word selected). Decided below, + // once the pages have had the click. + let present_click = view.presentation.is_some() + && navigation_enabled + && ui.rect_contains_pointer(navigation_rect) + && ui.input(|i| i.pointer.primary_clicked()) + && app.quick_tool == QuickTool::Select; + // Shift as held now, or as the click's own event carried it (a synthetic click). + let present_back = present_click + && ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| { + matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) + }) + }); if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { let z = view.zoom * zoom_delta; match ui.input(|i| i.pointer.hover_pos()) { @@ -3953,6 +4025,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } + // Full screen: the click turned the page unless it did something else first. + if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 { + view.step_page(!present_back); + } match clicked_link { Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info), Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), @@ -4071,8 +4147,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if copy_selected { app.execute("edit.copy"); } - // Read mode hides the tools (#2646). - if app.mode != crate::Mode::Read { + // Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459); + // the quick tools come back with the window. + if app.mode != crate::Mode::Read && !app.full_screen { quick_bar(app, avail, ui); } if let Some((source, file, destination)) = remote_open { diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index b769c0c00..e9d3b43d3 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -270,7 +270,7 @@ impl PdfCraftApp { let on = !self.full_screen; match self.ctx.clone() { Some(ctx) => self.set_full_screen(&ctx, on), - None => self.full_screen = on, + None => self.apply_full_screen(on), } } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index 63f803525..1e4e69603 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -103,7 +103,11 @@ impl PdfCraftApp { // The reading-colours preference is app-wide: the newly created document starts with it. self.session.set_reading_colors(id, self.reading_colors); let info = &self.session.get(id).ok_or("the new document could not be opened")?.info; - self.views.push(crate::DocView::new(id, info, self.view_defaults)); + let mut view = crate::DocView::new(id, info, self.view_defaults); + if self.full_screen { + view.enter_presentation(); + } + self.views.push(view); self.active = Some(self.views.len() - 1); Ok(()) } diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 03c773807..1ec9d383f 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -816,7 +816,11 @@ impl PdfCraftApp { // The reading-colours preference is app-wide: the newly created document starts with it. self.session.set_reading_colors(id, self.reading_colors); let Some(doc) = self.session.get(id) else { return }; - self.views.push(crate::DocView::new(id, &doc.info, self.view_defaults)); + let mut view = crate::DocView::new(id, &doc.info, self.view_defaults); + if self.full_screen { + view.enter_presentation(); + } + self.views.push(view); self.active = Some(self.views.len() - 1); self.notify_tr(message); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7b421263d..40be115df 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1338,7 +1338,11 @@ impl PdfCraftApp { }; } let initial = doc.initial_view(); - self.views.push(DocView::new(id, &doc.info, self.view_defaults)); + let mut view = DocView::new(id, &doc.info, self.view_defaults); + if self.full_screen { + view.enter_presentation(); + } + self.views.push(view); if activate { self.active = Some(self.views.len() - 1); } @@ -1582,10 +1586,24 @@ impl PdfCraftApp { /// Enter or leave full-screen reading (Acrobat: View ▸ Full Screen Mode, ⌘L). pub fn set_full_screen(&mut self, ctx: &egui::Context, on: bool) { - self.full_screen = on; + self.apply_full_screen(on); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); } + /// The full-screen state without the window: every document shows one page (or spread) at a + /// time fitted to the screen while it is on (Acrobat's Full Screen Mode, #379, #1459), and + /// gets its display back when it is off. + pub fn apply_full_screen(&mut self, on: bool) { + self.full_screen = on; + for view in &mut self.views { + if on { + view.enter_presentation(); + } else { + view.leave_presentation(); + } + } + } + /// Answer the Full Screen mode prompt a document raised on opening: enter it, or leave the /// window as it is. pub fn resolve_full_screen_prompt(&mut self, ctx: &egui::Context, allow: bool) { @@ -3131,19 +3149,16 @@ impl eframe::App for PdfCraftApp { self.window_title = title; } if self.full_screen && self.active.is_some() { - // Full screen: the page, nothing else (Esc or ⌘L to leave). - let t = theme::Tokens::get(&ctx); - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(if t.dark() { t.pasteboard } else { egui::Color32::from_gray(32) })).show( - ui, - |ui| { - if let Some(i) = self.active { - match compare_ui::side_by_side(self, i) { - Some((old, new)) => compare_ui::compare_area(self, old, new, ui), - None => canvas::document_area(self, i, ui), - } + // Full screen: the page on black, nothing else, as Acrobat's Full Screen Mode (Esc + // or ⌘L to leave). + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| { + if let Some(i) = self.active { + match compare_ui::side_by_side(self, i) { + Some((old, new)) => compare_ui::compare_area(self, old, new, ui), + None => canvas::document_area(self, i, ui), } - }, - ); + } + }); dialogs::show(self, &ctx); // Notices too: a refused field value or a failed save must be seen in full screen. self.show_progress(&ctx); diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 9cf99021a..f3d5717db 100644 --- a/crates/ui-egui/tests/view.rs +++ b/crates/ui-egui/tests/view.rs @@ -2,6 +2,7 @@ use egui::{Key, Modifiers, MouseWheelUnit, TouchPhase}; use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; use pdfcraft_ui_egui::PdfCraftApp; /// Three 300×400 pt pages. Page 1 links to page 3. @@ -1645,3 +1646,81 @@ fn long_view_menu_fits_a_short_window() { h.run_steps(4); assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks)); } + +fn click_with(h: &mut Harness<'static, PdfCraftApp>, pos: egui::Pos2, modifiers: Modifiers) { + h.hover_at(pos); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers }); + } + h.run_steps(4); +} + +/// Full screen is Acrobat's Full Screen Mode (#379, #1459): one page at a time, fitted to the +/// screen, with no tools; a click, Space, Enter or → go forward a page and a ⇧-click, Backspace +/// or ← back; Esc brings the display from before it back, at the page reached. +#[test] +fn full_screen_shows_one_fitted_page_and_clicks_turn_it() { + use pdfcraft_ui_egui::canvas::Fit; + let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]); + h.run_steps(4); + assert!(h.state().views[0].continuous(), "continuous before"); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + let v = &h.state().views[0]; + assert!(!v.continuous(), "one page at a time"); + assert_eq!(v.fit, Fit::Page, "fitted to the screen"); + assert!(rect(&h, 0).is_some() && rect(&h, 1).is_none(), "only page 1 is on screen"); + let r = rect(&h, 0).unwrap(); + let vp = h.state().views[0].viewport_rect(); + assert!(r.height() <= vp.height() + 1.0 && r.width() <= vp.width() + 1.0, "the page fits: {r:?} in {vp:?}"); + assert!(h.query_by_label("Select (V)").is_none(), "no quick tools in full screen"); + click_with(&mut h, r.center(), Modifiers::NONE); + assert_eq!(h.state().views[0].current, 1, "a click goes forward a page"); + h.key_press(Key::ArrowRight); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 2, "→ goes forward"); + h.key_press(Key::Backspace); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "Backspace goes back"); + let r = rect(&h, 1).expect("page 2 on screen"); + click_with(&mut h, r.center(), Modifiers::SHIFT); + assert_eq!(h.state().views[0].current, 0, "a shift-click goes back"); + h.key_press(Key::Space); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "Space goes forward"); + h.key_press(Key::Escape); + h.run_steps(4); + assert!(!h.state().full_screen, "Escape leaves full screen"); + let v = &h.state().views[0]; + assert!(v.continuous(), "the display from before comes back"); + assert_eq!(v.fit, Fit::None); + assert!((v.zoom - 0.5).abs() < 1e-3, "zoom back to 50%: {}", v.zoom); + assert_eq!(v.current, 1, "at the page reached in full screen"); + h.run_steps(2); + assert!(h.query_by_label("Select (V)").is_some(), "the quick tools are back"); +} + +/// Two-page view stays a spread in full screen (#1353): both pages of the spread, fitted, and +/// → turns the spread. +#[test] +fn full_screen_keeps_two_page_view_as_spreads() { + use pdfcraft_ui_egui::canvas::PageLayout; + let mut h = harness(&[("layout", "two-up")]); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let v = &h.state().views[0]; + assert_eq!(v.layout, PageLayout::TwoUp); + assert!(!v.continuous(), "one spread at a time"); + assert!(rect(&h, 0).is_some() && rect(&h, 1).is_some() && rect(&h, 2).is_none(), "the first spread only"); + h.key_press(Key::ArrowRight); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 2, "→ turns the spread"); + assert!(rect(&h, 2).is_some() && rect(&h, 0).is_none()); + h.key_press(Key::Escape); + h.run_steps(4); + let v = &h.state().views[0]; + assert!(v.continuous() && v.layout == PageLayout::TwoUp, "two-page continuous view comes back"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a78409fad..a6fc3c3de 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (column select #740, multi-object move #844, Shift-⌘-G #810 fixed) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (full screen presents one page at a time #1459) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the 2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is @@ -31,7 +31,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | | Split window, spreadsheet split | yes | no | planned | -| Read mode, full screen | yes | yes | shipped; presentation options planned | +| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned | | Light, dark, system theme | yes | yes | shipped | | Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704) | shipped | | Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | @@ -81,5 +81,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) | | 2026-10-11 | minor | Column select shipped (#740, PR #765); several objects move together on one page (#844, PR #907); Shift-⌘-G goes back (#810, PR #835) | | 2026-10-10 | major | Created from the command registry, automation tools, the View area of the checklist and open UI issues | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index e19efb0a0..2868a64ec 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1106,8 +1106,8 @@ milestone = "M3" status = "shipped" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves"] -notes = "" +evidence = ["crates/ui-egui/tests/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/view.rs::full_screen_keeps_two_page_view_as_spreads"] +notes = "Acrobat's Full Screen Mode (#1459): the page on black and nothing else, one page at a time fitted to the screen (one spread when two-page view was on, #1353), from the current page; a click, Space, Enter, \u2192 or Page Down go forward, a shift-click, Backspace, \u2190 or Page Up back, Home/End to the ends; Esc or \u2318L bring the display from before back at the page reached. Links still open on click. Missing: the cursor is not hidden, no transitions, auto-advance or loop (view.full-screen-presentation), and no full-screen preferences such as the background colour" [[feature]] id = "view.full-screen-presentation" @@ -1115,11 +1115,11 @@ title = "Full-screen presentation options (auto-advance, loop, click to advance) area = "B" tier = "P2" milestone = "M12" -status = "planned" -commands = [] +status = "partial" +commands = ["view.full_screen"] tools = [] -evidence = [] -notes = "" +evidence = ["crates/ui-egui/tests/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it"] +notes = "Click to advance (and shift-click back) ships with view.full-screen; auto-advance every N seconds, loop after the last page, page transitions and the full-screen preferences are not done" [[feature]] id = "view.reflow"