diff --git a/ROADMAP.md b/ROADMAP.md index 5da570273..796759163 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -270,6 +270,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 4dcf95b01..6eff992d8 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, @@ -456,6 +470,9 @@ pub struct DocView { clicks: u32, last_press_time: f64, last_press_pos: Option, + /// Full screen: a click's page turn, when it falls due (input time) and which way. It waits + /// out the double-click time, as the click may be the first of a double-click selecting a word. + present_turn: Option<(f64, bool)>, last_queue: Vec, /// Finished renders wait in the pool instead of reaching the view (tests): what the view /// shows before a raster arrives can be looked at however fast the renderer is. @@ -729,6 +746,7 @@ impl DocView { single_edges: None, flash: None, compare_marks: Vec::new(), + presentation: None, pages: HashMap::new(), errors: HashMap::new(), waiting_since: HashMap::new(), @@ -752,6 +770,7 @@ impl DocView { clicks: 0, last_press_time: 0.0, last_press_pos: None, + present_turn: None, last_queue: Vec::new(), hold_rasters: false, viewport_w: 800.0, @@ -852,9 +871,9 @@ impl DocView { } /// The document was replaced by a newer version of its file (#431). The reader's place - /// stays: zoom, layout, rotation, page, scroll, view history and the find bar, the snapshot - /// marker and its pending close, and the page images on screen until their replacements - /// are drawn, so nothing flashes. Everything that pointed into the old version (a selected + /// stays: zoom, layout, rotation, page, scroll, full screen, view history and the find bar, + /// the snapshot marker and its pending close, and the page images on screen until their + /// replacements are drawn, so nothing flashes. Everything that pointed into the old version (a selected /// comment, a field being typed in, text being edited, a drag, page selection) starts /// afresh, so no stale index reaches the new one. pub fn document_replaced(&mut self, info: &DocInfo) { @@ -875,6 +894,8 @@ impl DocView { fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; + // Still presenting, with the display to go back to on leaving. + fresh.presentation = self.presentation.take(); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -1284,8 +1305,7 @@ impl DocView { let last = self.page_count.saturating_sub(1); let (target, first) = match self.layout { PageLayout::TwoUp => { - // Spreads are [0, 1], [2, 3], … or, with a cover page, [0], [1, 2], [3, 4], …. - let first = if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 }; + let first = self.spread_first(c); let target = match (forward, self.cover && c == 0) { (true, true) => 1, (true, false) => first.saturating_add(2), @@ -1381,6 +1401,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. @@ -1967,14 +2018,30 @@ impl DocView { } } + /// The first page of the two-page spread page `c` is in: spreads are [0, 1], [2, 3], … or, + /// with a cover page, [0], [1, 2], [3, 4], …. + fn spread_first(&self, c: usize) -> usize { + if self.cover { c.saturating_sub((c + 1) % 2) } else { c - c % 2 } + } + fn fit_zoom(&mut self, info: &DocInfo) { let largest = |side: fn((f32, f32)) -> f32| info.pages.iter().map(|p| side(self.display_size(p))).fold(1.0, f32::max); let max_w = largest(|s| s.0); - // A single page shown on its own fits that page. Every other display (scrolling, or a - // two-page spread) fits its largest page, so the zoom holds still while pages of other - // sizes scroll or page past. + // A single page shown on its own fits that page, and full screen in two-page view fits + // the spread it shows (review on #2070). Every other display (scrolling, or two-page + // view in the window) fits its largest page, so the zoom holds still while pages of + // other sizes scroll or page past. let (w, h) = if self.layout == PageLayout::Single && !self.continuous { info.pages.get(self.current).map_or_else(|| (max_w, largest(|s| s.1)), |p| self.display_size(p)) + } else if self.presentation.is_some() && self.layout == PageLayout::TwoUp && !self.continuous { + let first = self.spread_first(self.current); + let last = if self.cover && first == 0 { 0 } else { first.saturating_add(1) }; + let spread: Vec<(f32, f32)> = info.pages.iter().skip(first).take(last - first + 1).map(|p| self.display_size(p)).collect(); + if spread.is_empty() { + (max_w, largest(|s| s.1)) + } else { + (spread.iter().map(|s| s.0).fold(1.0, f32::max), spread.iter().map(|s| s.1).fold(1.0, f32::max)) + } } else { (max_w, largest(|s| s.1)) }; @@ -2326,6 +2393,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); @@ -2501,6 +2578,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()) { @@ -4000,6 +4093,26 @@ 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 turns the page unless it did something else first. It waits out the + // double-click time: turning on the first click's release sent a double-click's second click, + // meant to select a word, to the next page (review on #2070). A selection cancels it, and so + // do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns + // once. + let now = ui.input(|i| i.time); + if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 { + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + view.present_turn = Some((now + delay, !present_back)); + } + if let Some((due, forward)) = view.present_turn { + if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() { + view.present_turn = None; + } else if now >= due { + view.present_turn = None; + view.step_page(forward); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); + } + } 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), @@ -4118,8 +4231,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 { @@ -6412,6 +6526,55 @@ trailer << /Root 1 0 R >> } } + /// Review on #2070: full screen in two-page view fits the spread it shows, the cover page + /// on its own included; two-page view in the window keeps one zoom for the document. + #[test] + fn full_screen_two_page_view_fits_the_spread_it_shows() { + let page = + |width: f32, height: f32| pdfcraft_render::PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, width, height], rotation: 0 }; + // Spreads [0, 1] small, [2, 3] large; with a cover page [0], [1, 2] mixed, [3]. + let info = DocInfo { pages: vec![page(300.0, 400.0), page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); + (v.layout, v.continuous, v.viewport_w, v.viewport_h) = (PageLayout::TwoUp, false, 1000.0, 800.0); + v.fit = Fit::Page; + v.fit_zoom(&info); + let windowed = v.zoom; + v.enter_presentation(); + v.fit_zoom(&info); + let small = v.zoom; + assert!(small > windowed * 1.9, "the small spread fills the screen: {small} vs {windowed}"); + v.current = 3; + v.fit_zoom(&info); + assert!((v.zoom - windowed).abs() < 1e-4, "the large spread fits as before"); + v.current = 1; + v.fit_zoom(&info); + assert_eq!(v.zoom, small, "page 2 is in the first spread"); + // With a cover page: [0] alone, then [1, 2] (the large page decides), then [3]. + v.cover = true; + v.current = 0; + v.fit_zoom(&info); + assert_eq!(v.zoom, small, "the cover page alone"); + v.current = 1; + v.fit_zoom(&info); + assert!((v.zoom - windowed).abs() < 1e-4, "page 2 shares a spread with the large page 3"); + // Scrolling while in full screen fits the document, as scrolling does in the window. + (v.continuous, v.current) = (true, 0); + v.fit_zoom(&info); + assert!((v.zoom - windowed).abs() < 1e-4, "continuous full screen keeps one zoom"); + v.continuous = false; + // The cover page is a spread of its own even when the next page is larger. + let mixed = DocInfo { pages: vec![page(300.0, 400.0), page(600.0, 800.0), page(600.0, 800.0)], ..Default::default() }; + v.current = 0; + v.fit_zoom(&mixed); + assert_eq!(v.zoom, small, "the small cover alone, not with page 2"); + // Leaving full screen brings the document-wide fit back. + v.leave_presentation(); + (v.fit, v.current) = (Fit::Page, 0); + (v.layout, v.continuous) = (PageLayout::TwoUp, false); + v.fit_zoom(&info); + assert!((v.zoom - windowed).abs() < 1e-4, "in the window, one zoom for the document"); + } + #[test] fn fit_width_fits_the_shown_page_in_single_page_view_only() { let page = diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 49a0e20b7..2f888764a 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 84467e60d..6d5c52b3e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -525,6 +525,8 @@ pub struct PdfCraftApp { pub quick_tool: QuickTool, /// The tool to go back to when Space, held for a temporary Hand, is released. space_hand: Option, + /// The tool before full screen, given back on leaving it (see [`Self::apply_full_screen`]). + full_screen_tool: Option, /// Comment author, per-tool colours and widths, pin. pub comment_prefs: comments::CommentPrefs, /// Resolved colours, including the current OS theme when following the system. @@ -919,6 +921,7 @@ impl PdfCraftApp { right_panel_closed: false, quick_tool: QuickTool::Select, space_hand: None, + full_screen_tool: None, comment_prefs: Default::default(), theme: ThemeKind::Light, theme_preference: ThemePreference::Light, @@ -1351,11 +1354,18 @@ impl PdfCraftApp { }; } let initial = doc.initial_view(); - self.views.push(DocView::new(id, &doc.info, self.view_defaults)); + let view = DocView::new(id, &doc.info, self.view_defaults); + self.views.push(view); if activate { self.active = Some(self.views.len() - 1); } self.apply_initial_view(self.views.len() - 1, &initial); + // After the document's own initial view, so leaving full screen comes back to it. + if self.full_screen + && let Some(view) = self.views.last_mut() + { + view.enter_presentation(); + } // Full screen is asked for, never taken: a document can't put the app in full screen on // its own (the prompt is `dialogs::full_screen_prompt`). if activate && initial.full_screen { @@ -1595,10 +1605,32 @@ 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) { + // A click turns the page only with the Select tool, and full screen hides the tools: + // select it there, and give the tool back on leaving (review on #2070). + if on && !self.full_screen { + self.full_screen_tool = Some(self.quick_tool); + self.quick_tool = QuickTool::Select; + } else if !on && let Some(tool) = self.full_screen_tool.take() { + self.quick_tool = tool; + } + 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) { @@ -3168,19 +3200,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/ui_group/text_select.rs b/crates/ui-egui/tests/ui_group/text_select.rs index 9ed4e9d5d..509775f6a 100644 --- a/crates/ui-egui/tests/ui_group/text_select.rs +++ b/crates/ui-egui/tests/ui_group/text_select.rs @@ -544,3 +544,84 @@ fn coalesced_primary_clicks_select_line_then_spaced_click_clears() { assert_eq!(doc.saved_bytes(), saved); } } + +/// Review on #2070: a click on the page in full screen waits out the double-click time before +/// it turns the page, and a link clicked meanwhile used to leave that turn pending: the link +/// jumped to page 3, then the page moved on to page 4. +#[test] +fn full_screen_link_click_cancels_the_pending_page_turn() { + let page = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>"; + let four_pages = String::from_utf8(two_lines()) + .unwrap() + .replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R 7 0 R 8 0 R] /Count 4") + .replace("/Contents 4 0 R /Resources", "/Annots [9 0 R] /Contents 4 0 R /Resources") + .replace( + "trailer", + &format!( + "6 0 obj {page} endobj\n7 0 obj {page} endobj\n8 0 obj {page} endobj\n9 0 obj << /Type /Annot /Subtype /Link /Rect [200 20 280 60] /Border [0 0 0] /Dest [7 0 R /Fit] >> endobj\ntrailer" + ), + ) + .into_bytes(); + let (mut h, _) = harness_with(four_pages, |_| {}); + h.state_mut().apply_full_screen(true); + crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending()); + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let at = |x: f32, y: f32| egui::pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height()); + // A click on blank page area, then, within the double-click time, a click on the link. + h.hover_at(at(150.0, 90.0)); + h.run_steps(1); + click(&mut h, at(150.0, 90.0)); + h.run_steps(3); + h.hover_at(at(240.0, 40.0)); + h.run_steps(1); + click(&mut h, at(240.0, 40.0)); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 2, "the link's page, and no turn after it"); +} + +/// Review on #2070: in full screen a click goes forward a page, but a double-click selects the +/// word and stays: the click waits out the double-click time before turning. Turning on the first +/// click's release had sent the second click to the next page. Leaving full screen meanwhile +/// drops the pending turn. +#[test] +fn full_screen_double_click_selects_a_word_and_stays_on_the_page() { + // The same page twice. + let two_pages = String::from_utf8(two_lines()) + .unwrap() + .replace("/Kids [3 0 R] /Count 1", "/Kids [3 0 R 6 0 R] /Count 2") + .replace( + "trailer", + "6 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\ntrailer", + ) + .into_bytes(); + let (mut h, _) = harness_with(two_pages, |_| {}); + h.state_mut().apply_full_screen(true); + crate::support::wait_until(&mut h, "the page in full screen", |app| app.views[0].glyph_screen_pos(0, 5).is_some() && !app.render_pending()); + let at = h.state().views[0].glyph_screen_pos(0, 5).expect("text layer"); + h.hover_at(at); + h.run_steps(1); + + // A double-click selects "quick" and stays on page 1, also once the double-click time is over. + click(&mut h, at); + click(&mut h, at); + assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 0, "the double-click didn't turn the page"); + + // A single click turns it once the double-click time is over. + // Entering full screen again clears the selection. + h.state_mut().apply_full_screen(false); + h.state_mut().apply_full_screen(true); + h.run_steps(30); + click(&mut h, at); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 1, "a click goes forward"); + + // A click right before leaving full screen doesn't turn the page after it. + h.state_mut().views[0].go_to_page(0); + h.run_steps(30); + click(&mut h, at); + h.state_mut().apply_full_screen(false); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 0, "no page turn after leaving"); +} diff --git a/crates/ui-egui/tests/view.rs b/crates/ui-egui/tests/view.rs index 39ea83df7..efffdedbd 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,139 @@ 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"); +} + +/// #2070 review: full screen survives the file being reloaded (Esc still brings the display from +/// before back), and a document opened in full screen takes its own initial view first, so Esc +/// comes back to that and full screen stays one spread at a time. +#[test] +fn full_screen_survives_a_reload_and_keeps_a_new_documents_own_view() { + use pdfcraft_ui_egui::canvas::{Fit, PageLayout}; + let mut h = harness(&[("layout", "continuous"), ("zoom", "50")]); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + { + let app = h.state_mut(); + let info = app.session.get(app.views[0].id).unwrap().info.clone(); + app.views[0].document_replaced(&info); + } + h.run_steps(4); + assert!(h.state().views[0].presenting(), "still presenting after the reload"); + h.key_press(Key::Escape); + h.run_steps(4); + let v = &h.state().views[0]; + assert!(v.continuous() && v.fit == Fit::None && (v.zoom - 0.5).abs() < 1e-3, "the display from before comes back"); + + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let two_columns = String::from_utf8_lossy(PAGES).replace("/Type /Catalog /Pages 2 0 R", "/Type /Catalog /Pages 2 0 R /PageLayout /TwoColumnLeft"); + h.state_mut().open_bytes("two.pdf", None, two_columns.into_bytes()).expect("opens"); + h.run_steps(6); + let v = h.state().views.last().unwrap(); + assert!(v.presenting() && v.layout == PageLayout::TwoUp && !v.continuous(), "one spread at a time"); + h.key_press(Key::Escape); + h.run_steps(4); + let v = h.state().views.last().unwrap(); + assert!(v.layout == PageLayout::TwoUp && v.continuous(), "the document's own two-column view comes 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"); +} + +/// Review on #2070: full screen hides the tools and a click turns the page only with Select, so +/// entering it from another tool selects Select, and leaving gives the tool back. +#[test] +fn full_screen_clicks_turn_pages_whatever_tool_was_on() { + use pdfcraft_ui_egui::QuickTool; + let mut h = harness(&[]); + h.run_steps(4); + h.state_mut().quick_tool = QuickTool::Hand; + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + assert_eq!(h.state().quick_tool, QuickTool::Select, "Select in full screen"); + // Entering again (a second document asking for it) keeps the tool to give back. + h.state_mut().apply_full_screen(true); + let r = rect(&h, 0).expect("page 1 on screen"); + click_with(&mut h, r.center(), Modifiers::NONE); + assert_eq!(h.state().views[0].current, 1, "a click goes forward"); + h.key_press(Key::Escape); + h.run_steps(4); + assert!(!h.state().full_screen); + assert_eq!(h.state().quick_tool, QuickTool::Hand, "the tool from before is back"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e8edbae40..9f2d0fd5d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,8 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.) - -> **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-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; 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 @@ -33,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); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped | | Destinations, articles, model tree, Tags, Order, Content panels | yes | no | planned | @@ -84,5 +82,6 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| | 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. | +| 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 891a6c7bc..9f30f44a8 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"