From f15e8c5115825a84def86f1a205219a5839588af Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:42:12 +0800 Subject: [PATCH 01/13] M3: full screen plays page transitions and advances by display time (#379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In full screen, a page's transition (/Trans, ISO 32000-2 §12.4.4, Table 164) plays when the page comes into view, from the outgoing page's raster to the incoming one's (render::transition geometry), and a page's display time (/Dur) advances to the next page once it is up. A still pointer hides after three seconds and shows again when it moves. - Transitions play in single-page view without view rotation, once the incoming page has rendered; spreads, a rotated view and the Animation effects preference set to off change pages at once. - Display times count from the end of the transition and stop at the last page; the reader can turn the page sooner. The document's durations are bounded (a transition at most 10 s). The logic lives in canvas/presenter.rs; canvas.rs keeps its state on DocView and calls it after drawing the pages, and the Animation effects preference reaches it through PageEnv. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 14 ++ crates/ui-egui/src/canvas/presenter.rs | 249 +++++++++++++++++++++++++ crates/ui-egui/src/canvas/zoom.rs | 8 + crates/ui-egui/tests/it/view.rs | 110 +++++++++++ 4 files changed, 381 insertions(+) create mode 100644 crates/ui-egui/src/canvas/presenter.rs diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8b280ba75d..34b643bd0e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -30,6 +30,7 @@ mod organize; pub(super) mod page_cursor; pub(super) mod page_pointer; pub(super) mod page_router; +mod presenter; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -322,6 +323,8 @@ pub struct DocView { /// 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, + /// Full screen's transitions, display times and pointer (see [`presenter`]). + pub(crate) presenter: presenter::Presenter, pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, @@ -636,6 +639,7 @@ impl DocView { flash: None, compare_marks: Vec::new(), presentation: None, + presenter: Default::default(), pages: HashMap::new(), errors: HashMap::new(), waiting_since: HashMap::new(), @@ -795,6 +799,7 @@ impl DocView { fresh.cover = self.cover; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); + fresh.presenter = std::mem::take(&mut self.presenter); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -856,6 +861,8 @@ struct PageEnv<'a> { glide: Vec2, hand: bool, info: &'a DocInfo, + /// The Animation effects preference as it applies now (full screen's transitions follow it). + animates: bool, initials: &'a Option, key_scroll: f32, middle_gesture: bool, @@ -1974,6 +1981,7 @@ fn draw_pages( y_shift, .. } = *env; + let animates = env.animates; // egui's drag responses accept every pointer button. Keep a wheel gesture from also // selecting text, drawing a mark, or panning with the Hand tool. if middle_gesture { @@ -2082,6 +2090,11 @@ fn draw_pages( let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } + // Full screen plays transitions over the page just drawn, and keeps time (#379). + if view.presentation.is_some() { + let now = ui.input(|i| i.time); + presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + } // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag @@ -3174,6 +3187,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let env = PageEnv { added: &added, allowed, + animates, author: &author, auto_delta, avail, diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs new file mode 100644 index 0000000000..7738cf906c --- /dev/null +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -0,0 +1,249 @@ +//! Full screen as a presentation (ISO 32000-2 §12.4.4): a page's transition (`/Trans`, Table 164) +//! plays when the page comes into view, a page with a display time (`/Dur`) advances by itself, +//! and the pointer hides while it stays still. +//! +//! Transitions play in single-page view without view rotation, from the outgoing page's raster +//! to the incoming one's once it has rendered; spreads, a rotated view and the Animation effects +//! preference set to off change pages at once. Display times count from the end of the +//! transition and stop at the last page. + +use egui::{Color32, CornerRadius, CursorIcon, Painter, Pos2, Rect, TextureHandle, pos2}; +use pdfcraft_render::transition::{self, Effect, Piece}; +use pdfcraft_render::{DocInfo, PageTransition}; + +use super::{DocView, PageLayout}; + +/// Seconds the pointer stays still before full screen hides it. +pub(crate) const HIDE_POINTER_AFTER: f64 = 3.0; +/// Bounds on a document's numbers, which are untrusted: the longest transition played and the +/// longest display time waited for. +const MAX_TRANSITION: f64 = 10.0; +const MAX_DISPLAY: f64 = 24.0 * 3600.0; + +/// What full screen remembers between frames. +#[derive(Default)] +pub(crate) struct Presenter { + /// The page wholly in view, and since when (its transition over). + shown: Option<(usize, f64)>, + turn: Option, + /// Where the pointer last moved to, and when. + pointer: Option<(Pos2, f64)>, +} + +/// A transition under way, from one page to the next. +struct Turn { + from_page: usize, + /// The outgoing page's raster, kept while the transition plays. + from: TextureHandle, + to_page: usize, + effect: Effect, + seconds: f64, + /// When the incoming page's raster arrived and the transition began. + started: Option, +} + +impl Presenter { + /// Whether a page transition is playing or waiting for its incoming page. + pub(crate) fn turning(&self) -> bool { + self.turn.is_some() + } +} + +/// The transition and display time of page `page` (0-based), if it has either. +fn entry(info: &DocInfo, page: usize) -> Option<&PageTransition> { + info.transitions.iter().find(|t| t.page == page) +} + +/// The effect a page's transition plays and its length in seconds; `None` for no visible effect. +fn effect_of(t: &PageTransition) -> Option<(Effect, f64)> { + let style = t.style?; + let effect = Effect::from_parts(style.name(), t.horizontal, t.inward, t.direction, t.scale); + let seconds = f64::from(t.duration); + (effect != Effect::Replace && seconds.is_finite() && seconds > 0.0).then(|| (effect, seconds.min(MAX_TRANSITION))) +} + +/// `rect` with the aspect ratio of a `w` by `h` page, as large as fits in `within` and centred +/// there. +fn fitted(w: f32, h: f32, within: Rect) -> Rect { + let (w, h) = (w.max(1.0), h.max(1.0)); + let k = (within.width() / w).min(within.height() / h); + if !k.is_finite() || k <= 0.0 { + return within; + } + Rect::from_center_size(within.center(), egui::vec2(w * k, h * k)) +} + +/// Where a piece of a page goes on screen and which part of its raster it shows: `dst` in `page` +/// (screen), `src` as texture coordinates, and its tint. `None` for a piece with nothing to show. +pub(crate) fn piece_on_screen(page: Rect, p: &Piece) -> Option<(Rect, Rect, Color32)> { + let alpha = if p.alpha.is_finite() { p.alpha.clamp(0.0, 1.0) } else { 0.0 }; + let at = |f: [f32; 4]| { + let f = f.map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }); + (pos2(f[0], f[1]), pos2(f[2], f[3])) + }; + let (d0, d1) = at(p.dst); + let (s0, s1) = at(p.src); + let dst = Rect::from_min_max(page.lerp_inside(d0.to_vec2()), page.lerp_inside(d1.to_vec2())); + let uv = Rect::from_min_max(s0, s1); + (alpha > 0.0 && dst.width() > 0.0 && dst.height() > 0.0).then(|| (dst, uv, Color32::WHITE.gamma_multiply(alpha))) +} + +fn paint(painter: &Painter, tex: &TextureHandle, page: Rect, pieces: &[Piece]) { + for p in pieces { + if let Some((dst, uv, tint)) = piece_on_screen(page, p) { + painter.image(tex.id(), dst, uv, tint); + } + } +} + +/// One frame of full screen for `view`, after its pages were drawn: start, paint or finish the +/// current page's transition, advance a page whose display time is up, and hide a still pointer. +/// `animates` is the Animation effects preference as it applies now. +pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: &egui::Context, now: f64, animates: bool, pointer: Option) { + let page = view.current; + let showing = view.presenter.shown.map(|(p, _)| p); + let arriving = view.presenter.turn.as_ref().map(|t| t.to_page); + if showing != Some(page) && arriving != Some(page) { + // A new page: play its transition from the page before, when there is one to play. + let plays = animates && view.layout == PageLayout::Single && view.rotation == 0; + let turn = showing.or(arriving).filter(|_| plays).and_then(|from_page| { + let (effect, seconds) = entry(info, page).and_then(effect_of)?; + let from = view.pages.get(&from_page)?.tex.clone(); + Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) + }); + view.presenter.shown = if turn.is_some() { None } else { Some((page, now)) }; + view.presenter.turn = turn; + } + + if let Some(turn) = view.presenter.turn.as_mut() { + let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r); + let incoming = view.pages.get(&turn.to_page).map(|p| p.tex.clone()); + match to_rect { + None => { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } + Some(to_rect) => { + // The outgoing page keeps its own shape, centred where the incoming one is. + let from_rect = info.pages.get(turn.from_page).map_or(to_rect, |p| fitted(p.width, p.height, to_rect.union(view.viewport_screen))); + let from_rect = if (from_rect.aspect_ratio() - to_rect.aspect_ratio()).abs() < 0.01 { to_rect } else { from_rect }; + let t = match (&incoming, turn.started) { + // Hold the outgoing page until the incoming one has rendered. + (None, _) => 0.0, + (Some(_), Some(start)) => ((now - start) / turn.seconds) as f32, + (Some(_), None) => { + turn.started = Some(now); + 0.0 + } + }; + if t >= 1.0 { + view.presenter.turn = None; + view.presenter.shown = Some((page, now)); + } else { + let f = transition::frame(&turn.effect, t); + painter.rect_filled(from_rect.union(to_rect), CornerRadius::ZERO, Color32::BLACK); + paint(painter, &turn.from, from_rect, &f.outgoing); + if let Some(tex) = &incoming { + paint(painter, tex, to_rect, &f.incoming); + } + ctx.request_repaint(); + } + } + } + } + + // A page with a display time advances once it is up (not past the last page). + if let Some((shown, since)) = view.presenter.shown + && shown == page + && page + 1 < info.pages.len() + && let Some(seconds) = entry(info, page).and_then(|t| t.display).map(f64::from).filter(|d| d.is_finite() && *d >= 0.0) + { + let left = since + seconds.min(MAX_DISPLAY) - now; + if left <= 0.0 { + view.step_page(true); + ctx.request_repaint(); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + } + + // The pointer hides once it has been still for a while, and comes back when it moves. + match pointer { + None => view.presenter.pointer = None, + Some(p) if view.presenter.pointer.map(|(q, _)| q) != Some(p) => view.presenter.pointer = Some((p, now)), + Some(_) => {} + } + if let Some((_, since)) = view.presenter.pointer { + let left = since + HIDE_POINTER_AFTER - now; + if left <= 0.0 { + ctx.set_cursor_icon(CursorIcon::None); + } else { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> Rect { + Rect::from_min_size(pos2(100.0, 50.0), egui::vec2(200.0, 400.0)) + } + + #[test] + fn a_piece_maps_page_fractions_to_the_screen_and_its_raster() { + let p = Piece { src: [0.0, 0.0, 0.5, 1.0], dst: [0.5, 0.0, 1.0, 1.0], alpha: 1.0 }; + let (dst, uv, tint) = piece_on_screen(page(), &p).unwrap(); + assert_eq!(dst, Rect::from_min_max(pos2(200.0, 50.0), pos2(300.0, 450.0))); + assert_eq!(uv, Rect::from_min_max(pos2(0.0, 0.0), pos2(0.5, 1.0))); + assert_eq!(tint, Color32::WHITE); + let faded = piece_on_screen(page(), &Piece { alpha: 0.5, ..p }).unwrap().2; + assert!(faded.a() > 0 && faded.a() < 255, "half transparent: {faded:?}"); + } + + #[test] + fn hostile_pieces_paint_nothing_or_stay_on_the_page() { + for p in [ + Piece { src: [0.0; 4], dst: [0.2, 0.2, 0.2, 0.9], alpha: 1.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: 0.0 }, + Piece { src: [0.0, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 1.0, 1.0], alpha: f32::NAN }, + ] { + assert!(piece_on_screen(page(), &p).is_none(), "{p:?}"); + } + let (dst, uv, _) = + piece_on_screen(page(), &Piece { src: [f32::NAN, -3.0, 9.0, f32::INFINITY], dst: [-1.0, f32::NAN, 2.0, 1.5], alpha: 1.0 }).unwrap(); + assert!(page().contains_rect(dst), "{dst:?}"); + assert!(Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)).contains_rect(uv), "{uv:?}"); + } + + #[test] + fn only_a_visible_effect_with_a_length_plays() { + use pdfcraft_render::TransitionStyle; + let t = |style, duration| PageTransition { + page: 1, + style, + duration, + horizontal: true, + inward: true, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + assert_eq!(effect_of(&t(Some(TransitionStyle::Wipe), 1.5)), Some((Effect::Wipe { direction: 0 }, 1.5))); + assert_eq!(effect_of(&t(Some(TransitionStyle::Fade), 600.0)).map(|e| e.1), Some(MAX_TRANSITION), "capped"); + for (style, duration) in + [(None, 1.0), (Some(TransitionStyle::Replace), 1.0), (Some(TransitionStyle::Fade), 0.0), (Some(TransitionStyle::Fade), f32::NAN)] + { + assert_eq!(effect_of(&t(style, duration)), None, "{style:?} {duration}"); + } + } + + #[test] + fn the_outgoing_page_keeps_its_shape() { + let r = fitted(600.0, 300.0, Rect::from_min_size(pos2(0.0, 0.0), egui::vec2(400.0, 400.0))); + assert_eq!(r, Rect::from_min_max(pos2(0.0, 100.0), pos2(400.0, 300.0))); + assert_eq!(fitted(0.0, f32::NAN, Rect::ZERO), Rect::ZERO); + } +} diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 91bc4feac8..04ae59a0be 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -35,12 +35,14 @@ impl DocView { self.continuous = false; self.fit = Fit::Page; self.selection = None; + self.presenter = Default::default(); 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.presenter = Default::default(); self.layout = saved.layout; self.continuous = saved.continuous; self.cover = saved.cover; @@ -65,6 +67,12 @@ impl DocView { self.presentation.is_some() } + /// Whether full screen is playing a page transition (or holding the page before it until the + /// next one has rendered). + pub fn presenting_a_transition(&self) -> bool { + self.presenter.turning() + } + /// 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. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index aef5ddae73..9e96746841 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2329,3 +2329,113 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { sidebar_key(&mut h, Key::A, Modifiers::COMMAND); assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } + +/// Three 300×400 pt pages; the second comes in with a one-second Wipe and stays one second +/// (`/Trans`, `/Dur`), the others have neither. +const PRESENTATION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Wipe /D 1 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The presentation document in full screen, frames a tenth of a second apart, every raster in. +fn presenting(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.1).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("slides.pdf", None, PRESENTATION.to_vec()).expect("opens"); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("animation_effects", "on").unwrap(); + for (k, v) in options { + app.set_option(k, v).unwrap(); + } + app + }); + h.run_steps(4); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(4); + assert!(h.state().full_screen); + settle(&mut h); + h +} + +/// Run frames until the renderer has nothing left to deliver (bounded, in real time: the +/// renderer's threads do not follow the harness clock). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..300 { + h.run_steps(1); + if !h.state().render_pending() { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } +} + +/// Run frames until `done` holds (bounded), returning the number of frames it took. +fn frames_until(h: &mut Harness<'static, PdfCraftApp>, done: impl Fn(&PdfCraftApp) -> bool) -> usize { + for n in 0..300 { + if done(h.state()) { + return n; + } + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("never happened"); +} + +/// #379: in full screen, a page's /Trans plays when it comes into view (from the page before, +/// once the new one has rendered), and its /Dur turns to the next page once it is up; the last +/// page, with no display time, stays. +#[test] +fn full_screen_plays_a_page_transition_and_honours_the_display_time() { + let mut h = presenting(&[]); + assert!(!h.state().views[0].presenting_a_transition(), "page 1 just shows"); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); + assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); + h.run_steps(5); + assert_eq!(h.state().views[0].current, 1, "half of its display time"); + h.run_steps(7); + assert_eq!(h.state().views[0].current, 2, "its one-second display time is up"); + assert!(!h.state().views[0].presenting_a_transition(), "page 3 has no transition"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 2, "the last page stays"); + h.key_press(Key::Escape); + h.run_steps(2); + assert!(!h.state().views[0].presenting_a_transition()); +} + +/// The page changes at once where a transition can't or shouldn't play: with the Animation +/// effects preference off, and with two-page spreads. +#[test] +fn full_screen_turns_at_once_without_animations_or_in_spreads() { + for options in [&[("animation_effects", "off")][..], &[("layout", "two-up")][..]] { + let mut h = presenting(options); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(!h.state().views[0].presenting_a_transition(), "{options:?}"); + } +} + +/// In full screen the pointer hides after three still seconds, and shows again when it moves. +#[test] +fn full_screen_hides_a_still_pointer() { + let mut h = presenting(&[]); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved"); + h.run_steps(32); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); + h.hover_at(centre + egui::vec2(40.0, 0.0)); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "moved again"); +} From d51ac9a547866a99b92a1204784b56d0c7faa623 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:43:02 +0800 Subject: [PATCH 02/13] M3: in full screen a right-click and Shift+Enter go back a page (#5315) A left click goes forward a page in full screen; a right-click now goes back one, and full screen shows no context menu, as it shows no tools (Adobe Acrobat Help, 2019, "Full Screen preferences", p. 69: "Left Click To Go Forward One Page; Right Click To Go Back One Page"). Enter goes forward a page and Shift+Enter now goes back; it went forward too (Adobe Acrobat Help, 2019, "Read a document in Full Screen mode", p. 66: "To go to the previous page, press Shift+Enter"). parity: view.full-screen and view.full-screen-presentation cite the new tests and say what is still missing; organize.page-transitions is now played. docs/ui-parity.md row and revision history updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 19 ++++++++++-- crates/ui-egui/src/canvas/navigation.rs | 11 ++++--- crates/ui-egui/tests/it/view.rs | 41 +++++++++++++++++++++++++ docs/ui-parity.md | 5 +-- parity/acrobat-features.toml | 10 +++--- 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 34b643bd0e..cd5fbbf8f7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2139,7 +2139,8 @@ fn draw_pages( if view.continuous { view.current = current; } - resp.context_menu(|ui| { + let presenting = view.presentation.is_some(); + page_context_menu(&resp, presenting, |ui| { // Preparing a form: the selected field's menu. if preparing && let Some((name, wi)) = view.prepare.selected.clone() { // Several fields: Align, Center, Distribute, Set Fields to Same Size. @@ -2860,8 +2861,22 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { /// 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 once the pages /// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +/// The pages' context menu, except in full screen, where a right-click turns back a page instead +/// ([`presentation_click_state`]). +fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(&mut egui::Ui)) { + if !presenting { + resp.context_menu(add); + } +} + fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { - let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + let presenting = view.presentation.is_some() && enabled; + // A right-click goes back a page, as a left click goes forward (*Adobe Acrobat Help*, 2019, + // "Full Screen preferences", p. 69). + if presenting && ui.rect_contains_pointer(navigation_rect) && ui.input(|i| i.pointer.secondary_clicked()) { + return Some(true); + } + let clicked = presenting && presentation_click(ui, navigation_rect); // Shift as held now, or as the click's own event carried it (a synthetic click). clicked.then(|| { ui.input(|i| { diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 731b2c2586..ae716c8d3c 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -397,13 +397,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). + // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back + // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the + // previous page, press Shift+Enter"). 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) { + let shift = ctx.input(|i| i.modifiers.shift); + if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } - if key(Key::Backspace) { + if key(Key::Backspace) || (key(Key::Enter) && shift) { view.step_page(false); } } diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 9e96746841..97aaf41074 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2439,3 +2439,44 @@ fn full_screen_hides_a_still_pointer() { h.run_steps(1); assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "moved again"); } + +/// In full screen a right-click goes back a page, as a left click goes forward (*Adobe Acrobat +/// Help*, 2019, "Full Screen preferences", p. 69: "Left Click To Go Forward One Page; Right Click +/// To Go Back One Page"), and no context menu opens. +#[test] +fn full_screen_right_click_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::ArrowRight); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1); + let centre = h.state().views[0].viewport_rect().center(); + h.hover_at(centre); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: centre, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(6); + assert_eq!(h.state().views[0].current, 0, "a right-click goes back"); + assert!(!egui::Popup::is_any_open(&h.ctx), "no context menu in full screen"); +} + +/// In full screen Enter goes forward a page and Shift+Enter back (*Adobe Acrobat Help*, 2019, +/// "Read a document in Full Screen mode", p. 66: "To go to the previous page, press Shift+Enter"). +#[test] +fn full_screen_shift_enter_goes_back_a_page() { + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + h.key_press(Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 1, "Enter goes forward"); + h.key_press_modifiers(Modifiers::SHIFT, Key::Enter); + h.run_steps(4); + assert_eq!(h.state().views[0].current, 0, "Shift+Enter goes back"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 86eb0c66e7..b1b41c7212 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **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; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS) +> **Last reviewed:** 2026-10-29 · **Last updated:** 2026-10-29 · **Change:** minor (full screen plays page transitions and display times, hides a still pointer, and a right-click or Shift+Enter goes back a page #379) · **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; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned | +| Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences 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 | @@ -82,6 +82,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Full screen plays page transitions (/Trans) and display times (/Dur), hides a still pointer, and a right-click or Shift+Enter goes back a page (#379) | | 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-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) | | 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) | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 0b6f658dd6..f34fd12414 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1118,8 +1118,8 @@ milestone = "M3" status = "shipped" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/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" +evidence = ["crates/ui-egui/tests/it/commands.rs::full_screen_toggles_by_shortcut_and_command_and_escape_leaves", "crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_keeps_two_page_view_as_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_right_click_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_shift_enter_goes_back_a_page", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +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, a right-click (no context menu in full screen), Shift+Enter, 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. The pointer hides after three still seconds. Transitions and display times: view.full-screen-presentation. Missing: loop after the last page, and full-screen preferences such as the background colour" [[feature]] id = "view.full-screen-presentation" @@ -1130,8 +1130,8 @@ milestone = "M12" status = "partial" commands = ["view.full_screen"] tools = [] -evidence = ["crates/ui-egui/tests/it/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" +evidence = ["crates/ui-egui/tests/it/view.rs::full_screen_shows_one_fitted_page_and_clicks_turn_it", "crates/ui-egui/tests/it/view.rs::full_screen_plays_a_page_transition_and_honours_the_display_time", "crates/ui-egui/tests/it/view.rs::full_screen_turns_at_once_without_animations_or_in_spreads", "crates/ui-egui/tests/it/view.rs::full_screen_hides_a_still_pointer"] +notes = "Click to advance (and shift-click back) ships with view.full-screen. A page's /Trans plays when it comes into view (ISO 32000-2 \u00a712.4.4, Table 164; single-page view, no view rotation; the outgoing page is held until the incoming one has rendered; the Animation effects preference turns them off), a page's /Dur advances to the next page once it is up (not past the last), and the pointer hides after three still seconds. Not done: full-screen preferences that override the document (advance every N seconds, loop after the last page, a default transition, ignoring the document's transitions, the pointer setting), and transitions between two-page spreads" [[feature]] id = "view.reflow" @@ -3232,7 +3232,7 @@ status = "partial" commands = [] tools = ["doc_info", "page_transition_set"] evidence = ["crates/render/src/inspect/tests.rs::page_transitions_and_display_times_are_read", "crates/automation/tests/it/automation.rs::doc_info_lists_page_transitions_and_display_times", "crates/organize/src/tests.rs::page_transitions_are_set_removed_and_checked", "crates/automation/tests/it/automation.rs::page_transitions_are_set_and_removed_through_the_tools"] -notes = "Read and set, not played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Not yet: playing them in full screen, auto-advancing on /Dur, and the Page Transitions dialog" +notes = "Read, set and played: each page's /Trans (style, duration, dimension, motion, direction, Fly scale and backdrop) and /Dur display time are read with the spec's defaults and listed by doc_info (DocInfo::transitions); page_transition_set (Edit::SetTransition, one undo step) writes or removes them on chosen pages with the values checked first. Full screen plays them and advances on /Dur (view.full-screen-presentation). Not yet: the Page Transitions dialog" [[feature]] id = "organize.page-tab-order" From 689b5790c45a065c4b955c267bd45030baa3b362 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 01:40:11 +0800 Subject: [PATCH 03/13] M3: full screen review fixes: a new document starts afresh, a failed page ends its transition, no redraw at the last spread, the pointer hides over links - Replacing the document clears the presenter: no transition, page or timer of the old document carries over. - A transition into a page that failed to render ends at once instead of waiting for a texture that never comes. - A display time that expires on the last spread asks for a redraw only when the page actually moved, so the last spread doesn't redraw every frame. - A still pointer hides through a cursor claim on the top layer, which every tool reaches, so it hides over a link too, and its tip with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 12 +++-- crates/ui-egui/src/canvas/presenter.rs | 41 +++++++++----- crates/ui-egui/src/canvas/tests.rs | 74 ++++++++++++++++++++++++++ crates/ui-egui/tests/it/view.rs | 34 ++++++++++++ 4 files changed, 145 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cd5fbbf8f7..412ba11a58 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -799,7 +799,8 @@ impl DocView { fresh.cover = self.cover; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); - fresh.presenter = std::mem::take(&mut self.presenter); + // A new document: none of the old one's transition, page or timer carries over. + fresh.presenter = Default::default(); fresh.back = std::mem::take(&mut self.back); fresh.forward = std::mem::take(&mut self.forward); fresh.find = self.find.take(); @@ -2091,12 +2092,17 @@ fn draw_pages( crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); } // Full screen plays transitions over the page just drawn, and keeps time (#379). + let mut hide_pointer = false; if view.presentation.is_some() { let now = ui.input(|i| i.time); - presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); + } + // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. + if hide_pointer { + view.cursor.claim(Layer::Drag, egui::CursorIcon::None); } // The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip. - let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool)); + let hover_text = pointer.filter(|_| unobstructed && !hide_pointer).zip(view.cursor.resolve(ui.ctx(), tool)); // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the // pointer leaves the selection's page for the gap or the next page; over the whole diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 7738cf906c..dd43132731 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -7,7 +7,7 @@ //! preference set to off change pages at once. Display times count from the end of the //! transition and stop at the last page. -use egui::{Color32, CornerRadius, CursorIcon, Painter, Pos2, Rect, TextureHandle, pos2}; +use egui::{Color32, CornerRadius, Painter, Pos2, Rect, TextureHandle, pos2}; use pdfcraft_render::transition::{self, Effect, Piece}; use pdfcraft_render::{DocInfo, PageTransition}; @@ -97,9 +97,19 @@ fn paint(painter: &Painter, tex: &TextureHandle, page: Rect, pieces: &[Piece]) { } /// One frame of full screen for `view`, after its pages were drawn: start, paint or finish the -/// current page's transition, advance a page whose display time is up, and hide a still pointer. -/// `animates` is the Animation effects preference as it applies now. -pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: &egui::Context, now: f64, animates: bool, pointer: Option) { +/// current page's transition, and advance a page whose display time is up. Returns whether the +/// pointer has been still long enough to hide: the caller hides it once the page has chosen its +/// cursor, which would otherwise show it again over a link. `animates` is the Animation effects +/// preference as it applies now. +pub(super) fn frame( + view: &mut DocView, + info: &DocInfo, + painter: &Painter, + ctx: &egui::Context, + now: f64, + animates: bool, + pointer: Option, +) -> bool { let page = view.current; let showing = view.presenter.shown.map(|(p, _)| p); let arriving = view.presenter.turn.as_ref().map(|t| t.to_page); @@ -116,7 +126,10 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: } if let Some(turn) = view.presenter.turn.as_mut() { - let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r); + // A page the renderer can't draw ends the turn: waiting for it would hold the page before + // forever, and its display time would never start. + let failed = view.errors.contains_key(&turn.to_page); + let to_rect = view.screen_rects.iter().find(|(i, _)| *i == turn.to_page).map(|&(_, r)| r).filter(|_| !failed); let incoming = view.pages.get(&turn.to_page).map(|p| p.tex.clone()); match to_rect { None => { @@ -160,8 +173,12 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: { let left = since + seconds.min(MAX_DISPLAY) - now; if left <= 0.0 { + // In two-page view the last spread can't advance: then there is nothing to redraw. + let before = view.current; view.step_page(true); - ctx.request_repaint(); + if view.current != before { + ctx.request_repaint(); + } } else { ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); } @@ -173,14 +190,12 @@ pub(super) fn frame(view: &mut DocView, info: &DocInfo, painter: &Painter, ctx: Some(p) if view.presenter.pointer.map(|(q, _)| q) != Some(p) => view.presenter.pointer = Some((p, now)), Some(_) => {} } - if let Some((_, since)) = view.presenter.pointer { - let left = since + HIDE_POINTER_AFTER - now; - if left <= 0.0 { - ctx.set_cursor_icon(CursorIcon::None); - } else { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); - } + let Some((_, since)) = view.presenter.pointer else { return false }; + let left = since + HIDE_POINTER_AFTER - now; + if left > 0.0 { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(left)); } + left <= 0.0 } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 634a885111..b2e5974f08 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2002,3 +2002,77 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() h.state_mut().views[0].stale_tiles.clear(); h.state_mut().views[0].tiles.clear(); } + +/// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be +/// drawn again at once. +fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + presenter::frame(v, info, &painter, ui.ctx(), now, true, None); + }); + // No renderer here to upload textures: drop the frame's texture changes. + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|o| o.repaint_delay.is_zero()) +} + +/// A transition into a page the renderer could not draw ends at once: the page before is not held +/// on screen waiting for it, and the page's own time can start. +#[test] +fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + v.errors.insert(1, "the renderer failed".into()); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); +} + +/// A display time on the first page of the last two-page spread can't advance (there is no spread +/// after it): the presenter then stays on it without asking for a frame after frame. +#[test] +fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 2, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + v.current = 2; + let ctx = egui::Context::default(); + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.0, 0.5, 0.6] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 5.0); + assert_eq!(v.current, 2, "the last spread stays"); + assert!(!again, "and asks for no frame after frame"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 97aaf41074..815ce284d3 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2480,3 +2480,37 @@ fn full_screen_shift_enter_goes_back_a_page() { h.run_steps(4); assert_eq!(h.state().views[0].current, 0, "Shift+Enter goes back"); } + +/// A document replaced while full screen plays a transition (a reload) starts afresh: none of the +/// old document's transition, page or timer carries over. +#[test] +fn full_screen_starts_afresh_when_the_document_is_replaced() { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + { + let app = h.state_mut(); + let info = app.session.get(app.views[0].id).unwrap().info.clone(); + app.views[0].document_replaced(&info); + } + assert!(!h.state().views[0].presenting_a_transition(), "the old transition is dropped"); +} + +/// A still pointer hides over a link too: the page's own cursor (the hand over a link) is chosen +/// first, and full screen hides the pointer after it. +#[test] +fn full_screen_hides_a_still_pointer_over_a_link() { + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + // Page 1's link covers x 50–250, y 300–350 of the 300×400 pt page (from its bottom). + let r = rect(&h, 0).expect("page 1 on screen"); + let on_link = egui::pos2(r.left() + r.width() * 150.0 / 300.0, r.top() + r.height() * 75.0 / 400.0); + h.hover_at(on_link); + h.run_steps(1); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "just moved, over the link"); + h.run_steps(16); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); +} From 94d3e628b0ce07f6239e59a9409d760963b21ffc Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:02:45 +0800 Subject: [PATCH 04/13] M3: full screen clicks on fields and notes, Shift+Ctrl first and last page, no redraw while a page renders - A click on a link, form field or comment turns no page in full screen: the pointer stays active to click links and open notes (Adobe Acrobat Help, 2019, p. 65). - Shift+Ctrl+Page Up / Up Arrow go to the first page and Shift+Ctrl+Page Down / Down Arrow to the last, as Home and End (p. 83). - A transition waiting for its incoming page looks again every 50 ms rather than asking for every frame; the render cache wakes the window. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 20 ++++-- crates/ui-egui/src/canvas/navigation.rs | 15 ++-- crates/ui-egui/src/canvas/page_cursor.rs | 10 +++ crates/ui-egui/src/canvas/presenter.rs | 6 +- crates/ui-egui/src/canvas/tests.rs | 35 ++++++++++ crates/ui-egui/tests/it/view.rs | 87 ++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 412ba11a58..90464d90d6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -911,6 +911,9 @@ struct PageOut { open_signature: bool, open_table: Option<(usize, [f64; 4])>, primary_down: bool, + /// The pointer is over a link, comment, field or what is placed on the page: a click there is + /// for it, and turns no page in full screen. + on_object: bool, refused: Option, stamp_placed: bool, } @@ -2082,6 +2085,7 @@ fn draw_pages( open_signature, open_table, primary_down: _, + on_object: _, refused, stamp_placed, } = page_out; @@ -2097,6 +2101,8 @@ fn draw_pages( let now = ui.input(|i| i.time); hide_pointer = presenter::frame(view, info, painter, ui.ctx(), now, animates, pointer); } + // Read before the claims are spent below, and before the pointer hides, which claims the top layer. + let on_object = view.cursor.over_object(tool); // A still pointer hides over a link too, and its tip with it: the top layer, which every tool reaches. if hide_pointer { view.cursor.claim(Layer::Drag, egui::CursorIcon::None); @@ -2269,6 +2275,7 @@ fn draw_pages( open_signature, open_table, primary_down: ptr.primary_down, + on_object, refused, stamp_placed, }; @@ -2770,12 +2777,14 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, prim /// 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. +/// click on a link, field or comment (`clicked_object`) and leaving full screen: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65), and +/// fields to fill. A double-click elsewhere still turns once. /// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. -fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, clicked_object: bool) { let now = ui.input(|i| i.time); if let Some(back) = click - && !link_followed + && !clicked_object && view.selection.is_none() && view.clicks < 2 { @@ -2783,7 +2792,7 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option= due { view.present_turn = None; @@ -3281,6 +3290,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_signature, open_table, primary_down, + on_object, mut refused, stamp_placed, } = page_out; @@ -3354,7 +3364,7 @@ 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 => {} } - schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + schedule_presentation_turn(ui, view, present, clicked_link.is_some() || on_object && present.is_some()); let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index ae716c8d3c..24f358bc6b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -391,18 +391,26 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } - if key(Key::Home) { + // First and last page: Home and End, or Shift+Ctrl+Page Up / Up Arrow and Shift+Ctrl+Page Down / + // Down Arrow (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", p. 83). These take the + // key from the page turning and scrolling below. + let (command, shift) = ctx.input(|i| (i.modifiers.command, i.modifiers.shift)); + let to_end = command && shift && (key(Key::PageDown) || key(Key::ArrowDown)); + let to_start = command && shift && (key(Key::PageUp) || key(Key::ArrowUp)); + if key(Key::Home) || to_start { view.go_to_page(0); } - if key(Key::End) { + if key(Key::End) || to_end { view.go_to_page(usize::MAX); } + if to_end || to_start { + return; + } // Full screen: Space and Enter go forward a page, and Shift+Enter and Backspace back // (*Adobe Acrobat Help*, 2019, "Read a document in Full Screen mode", p. 66: "To go to the // previous page, press Shift+Enter"). The arrows and Page Up / Page Down already turn pages // where nothing scrolls, below. if view.presentation.is_some() { - let shift = ctx.input(|i| i.modifiers.shift); if key(Key::Space) || (key(Key::Enter) && !shift) { view.step_page(true); } @@ -412,7 +420,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } // 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); let scrolls = view.continuous; if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) { view.step_page(true); diff --git a/crates/ui-egui/src/canvas/page_cursor.rs b/crates/ui-egui/src/canvas/page_cursor.rs index e40dc1c4f3..b820f74d77 100644 --- a/crates/ui-egui/src/canvas/page_cursor.rs +++ b/crates/ui-egui/src/canvas/page_cursor.rs @@ -109,6 +109,16 @@ impl PageCursor { } } + /// Whether the pointer is over something a press with `tool` acts on in its own right: a + /// link, a comment, a field, or what is placed on the page and its grips and editors. Not the + /// page and its text, nor a drag, whose layer also hides the pointer in full screen. + pub(crate) fn over_object(&self, tool: QuickTool) -> bool { + LAYERS + .into_iter() + .zip(&self.claims) + .any(|(layer, claim)| claim.is_some() && (Layer::Link..Layer::Drag).contains(&layer) && layer.reached_by(tool)) + } + /// The claim on the highest layer `tool` reaches. fn winner(claims: [Option; LAYERS.len()], tool: QuickTool) -> Option { LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool))) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index dd43132731..3ecd16e882 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -158,8 +158,12 @@ pub(super) fn frame( paint(painter, &turn.from, from_rect, &f.outgoing); if let Some(tex) = &incoming { paint(painter, tex, to_rect, &f.incoming); + ctx.request_repaint(); + } else { + // Nothing moves until the page arrives, and the render cache wakes the window + // while pages are queued: look again now and then rather than every frame. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); } - ctx.request_repaint(); } } } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b2e5974f08..0610439118 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2046,6 +2046,41 @@ fn a_transition_into_a_page_that_failed_to_render_ends_at_once() { assert!(!v.presenting_a_transition(), "no wait for a page that won't render"); } +/// While the incoming page is still rendering, the presenter holds the page before without asking +/// for a frame after frame: the render cache wakes the window while pages are queued. +#[test] +fn a_transition_waiting_for_its_page_does_not_redraw_every_frame() { + use pdfcraft_render::{PageTransition, TransitionStyle}; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let wipe = PageTransition { + page: 1, + style: Some(TransitionStyle::Wipe), + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: None, + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![wipe], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 0.0); + v.current = 1; + v.screen_rects = vec![(1, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; + // egui asks for its first frames itself: past them, only the presenter asks. + for now in [0.1, 0.2, 0.3] { + presenter_frame(&ctx, &mut v, &info, now); + } + let again = presenter_frame(&ctx, &mut v, &info, 0.4); + assert!(v.presenting_a_transition(), "still waiting for page 2"); + assert!(!again, "without asking for a frame after frame"); +} + /// A display time on the first page of the last two-page spread can't advance (there is no spread /// after it): the presenter then stays on it without asking for a frame after frame. #[test] diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 815ce284d3..5f6ed64783 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2514,3 +2514,90 @@ fn full_screen_hides_a_still_pointer_over_a_link() { h.run_steps(16); assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for over three seconds"); } + +/// A text field on page 1 of three. +const FIELD_PAGES: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [6 0 R] >> endobj +4 0 obj << /Type /Page /Parent 2 0 R >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Full screen keeps the pointer active "so that you can click links and open notes" (*Adobe +/// Acrobat Help*, 2019, p. 65): a click on a form field opens it for typing rather than turning +/// the page, and a click beside it still turns. +#[test] +fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { + let mut h = harness(&[]); + h.state_mut().open_bytes("field.pdf", None, FIELD_PAGES.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(8); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the field is open for typing"); + click_with(&mut h, egui::pos2(r.center().x, r.top() + 20.0), Modifiers::NONE); + h.run_steps(4); + assert_eq!(h.state().views[n].current, 1, "a click beside the field turns the page"); +} + +/// Shift+Ctrl+Page Down or Down Arrow goes to the last page, and Shift+Ctrl+Page Up or Up Arrow +/// to the first, as Home and End do (*Adobe Acrobat Help*, 2019, "Keys for navigating a PDF", +/// p. 83), in full screen and out of it. +#[test] +fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { + for full_screen in [false, true] { + let mut h = harness(&[("layout", "single")]); + if full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + let both = Modifiers::COMMAND | Modifiers::SHIFT; + for (k, want) in [(Key::PageDown, 2), (Key::PageUp, 0), (Key::ArrowDown, 2), (Key::ArrowUp, 0)] { + h.key_press_modifiers(both, k); + h.run_steps(6); + assert_eq!(h.state().views[0].current, want, "{k:?}, full screen {full_screen}"); + } + } +} + +/// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays +/// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). +#[test] +fn full_screen_double_click_on_a_note_opens_it_and_turns_no_page() { + const NOTE: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [100 100 124 124] /Contents (Hello) /T (A) /F 4 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = harness_stepping(1.0 / 60.0, &[]); + h.state_mut().open_bytes("note.pdf", None, NOTE.to_vec()).expect("opens"); + h.run_steps(8); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + let n = h.state().views.len() - 1; + let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); + let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); + h.hover_at(p); + h.run_steps(2); + for _ in 0..2 { + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); + } + // Past the double-click time, when a turn would have come. + h.run_steps(30); + assert_eq!(h.state().views[n].current, 0, "no page turned"); + assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); +} From 2d21753b1ed5e7a5a73ab3b9fb365859365f3e06 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:58:59 +0800 Subject: [PATCH 05/13] M3: a page's display time starts once it has rendered; the Wipe test checks what is painted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review on #5316: - Full screen started a page's /Dur (ISO 32000-2 §12.4.4.1) when the page became current, so a slow render could use up its display time while the page still said Rendering…. The time now starts once the page's raster has arrived, or at once for a page the renderer can't draw. Test: a_display_time_starts_once_the_page_has_rendered, which fails with the fix reverted. a_display_time_on_the_last_spread_does_not_redraw_every_frame now gives its page a raster, so it still reaches the display time. - full_screen_plays_a_page_transition_and_honours_the_display_time also checks the Wipe's painted output: a textured strip of page 2 as tall as the page and only part of its width. It fails when the presenter stops painting the incoming page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas/presenter.rs | 5 +++- crates/ui-egui/src/canvas/tests.rs | 36 ++++++++++++++++++++++++++ crates/ui-egui/tests/it/view.rs | 25 +++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 3ecd16e882..8b98a8b4f6 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -121,7 +121,10 @@ pub(super) fn frame( let from = view.pages.get(&from_page)?.tex.clone(); Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) }); - view.presenter.shown = if turn.is_some() { None } else { Some((page, now)) }; + // Its display time starts once the page is on screen, not while it still says Rendering… + // (or at once for a page the renderer can't draw, which never will be). + let ready = view.pages.contains_key(&page) || view.errors.contains_key(&page); + view.presenter.shown = if turn.is_some() || !ready { None } else { Some((page, now)) }; view.presenter.turn = turn; } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 0610439118..3efa809d74 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2103,6 +2103,8 @@ fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { v.enter_presentation(); v.current = 2; let ctx = egui::Context::default(); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(2, super::cache::PageTex { tag: 0, tex }); // egui asks for its first frames itself: past them, only the presenter asks. for now in [0.0, 0.5, 0.6] { presenter_frame(&ctx, &mut v, &info, now); @@ -2111,3 +2113,37 @@ fn a_display_time_on_the_last_spread_does_not_redraw_every_frame() { assert_eq!(v.current, 2, "the last spread stays"); assert!(!again, "and asks for no frame after frame"); } + +/// A page's display time (ISO 32000-2 §12.4.4.1, `/Dur`) counts from when the page is on screen: +/// a slow render that still shows Rendering… does not use it up. +#[test] +fn a_display_time_starts_once_the_page_has_rendered() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 1 is still rendering: its second has not started"); + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(0, super::cache::PageTex { tag: 0, tex }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 1, "its second on screen is up"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 5f6ed64783..e4fdad63d3 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2398,7 +2398,30 @@ fn full_screen_plays_a_page_transition_and_honours_the_display_time() { h.run_steps(1); assert_eq!(h.state().views[0].current, 1); assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); - let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + // The Wipe is painted, not only timed: once page 2 has rendered, a frame shows a strip of it as + // tall as the page and only part of its width. + let wipe_strip = |h: &Harness<'static, PdfCraftApp>| { + h.state().views[0].page_screen_rect(1).is_some_and(|page| { + h.output().shapes.iter().any(|clipped| { + let egui::Shape::Mesh(mesh) = &clipped.shape else { return false }; + let b = mesh.calc_bounds(); + mesh.texture_id != egui::TextureId::default() + && (b.min.x - page.min.x).abs() < 1.0 + && (b.min.y - page.min.y).abs() < 1.0 + && (b.max.y - page.max.y).abs() < 1.0 + && b.width() > 1.0 + && b.width() < page.width() - 1.0 + }) + }) + }; + let mut steps = 0; + while !wipe_strip(&h) && steps < 300 { + h.run_steps(1); + steps += 1; + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(wipe_strip(&h), "the Wipe paints part of page 2 ({steps} frames)"); + let frames = steps + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); h.run_steps(5); From bdda289585317a0184dc94a4e9b65646b36fc70d Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:21:54 +0800 Subject: [PATCH 06/13] M3: a two-page spread's display time waits for both pages; the parity row says what full screen does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (CodeRabbit): in two-page view the /Dur timer started once the current page had rendered, so the spread could turn while its other page still showed Rendering…; it now waits for every page on screen. The ui-parity row says full screen shows a fitted page or spread and that background clicks and keys turn pages while clicks on page objects don't. The doc comment of presentation_click_state is back on its function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 6 ++--- crates/ui-egui/src/canvas/presenter.rs | 6 +++-- crates/ui-egui/src/canvas/tests.rs | 37 ++++++++++++++++++++++++++ docs/ui-parity.md | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 90464d90d6..61a266da88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -2873,9 +2873,6 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { } } -/// 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 once the pages -/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. /// The pages' context menu, except in full screen, where a right-click turns back a page instead /// ([`presentation_click_state`]). fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(&mut egui::Ui)) { @@ -2884,6 +2881,9 @@ fn page_context_menu(resp: &egui::Response, presenting: bool, add: impl FnOnce(& } } +/// 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 once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { let presenting = view.presentation.is_some() && enabled; // A right-click goes back a page, as a left click goes forward (*Adobe Acrobat Help*, 2019, diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 8b98a8b4f6..b130a1b9e3 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -122,8 +122,10 @@ pub(super) fn frame( Some(Turn { from_page, from, to_page: page, effect, seconds, started: None }) }); // Its display time starts once the page is on screen, not while it still says Rendering… - // (or at once for a page the renderer can't draw, which never will be). - let ready = view.pages.contains_key(&page) || view.errors.contains_key(&page); + // (or at once for a page the renderer can't draw, which never will be). In two-page view + // that is every page of the spread, so the spread isn't turned before both were shown. + let drawn = |p: &usize| view.pages.contains_key(p) || view.errors.contains_key(p); + let ready = drawn(&page) && view.screen_rects.iter().all(|(p, _)| drawn(p)); view.presenter.shown = if turn.is_some() || !ready { None } else { Some((page, now)) }; view.presenter.turn = turn; } diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index 3efa809d74..c10f3a35be 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2147,3 +2147,40 @@ fn a_display_time_starts_once_the_page_has_rendered() { presenter_frame(&ctx, &mut v, &info, 6.6); assert_eq!(v.current, 1, "its second on screen is up"); } + +/// In two-page view a display time counts from when the whole spread is on screen: one page of +/// it still showing Rendering… holds the spread (#5316 review). +#[test] +fn a_display_time_in_two_page_view_waits_for_the_whole_spread() { + use pdfcraft_render::PageTransition; + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let timed = PageTransition { + page: 0, + style: None, + duration: 1.0, + horizontal: false, + inward: false, + direction: Some(0), + scale: 1.0, + opaque: false, + display: Some(1.0), + }; + let info = DocInfo { pages: vec![page.clone(), page.clone(), page.clone(), page], transitions: vec![timed], ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::TwoUp, continuous: false, ..Default::default() }); + v.enter_presentation(); + let ctx = egui::Context::default(); + let tex = |ctx: &egui::Context| ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + let r = Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)); + v.screen_rects = vec![(0, r), (1, r.translate(egui::vec2(300.0, 0.0)))]; + v.pages.insert(0, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + for now in [0.0, 1.0, 2.0, 5.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "page 2 of the spread is still rendering: the spread's second has not started"); + v.pages.insert(1, super::cache::PageTex { tag: 0, tex: tex(&ctx) }); + presenter_frame(&ctx, &mut v, &info, 5.5); + presenter_frame(&ctx, &mut v, &info, 6.0); + assert_eq!(v.current, 0, "half of its second on screen"); + presenter_frame(&ctx, &mut v, &info, 6.6); + assert_eq!(v.current, 2, "its second on screen is up: the next spread"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b1b41c7212..eabfb9da1f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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; full screen is one fitted page at a time on black, click/keys turn pages (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences planned | +| Read mode, full screen | yes | yes; full screen is one fitted page or spread at a time on black, and background clicks and keys turn pages while clicks on links, comments, fields and other page objects do not (#1459), a right-click or Shift+Enter goes back, page transitions and display times play (#379), a still pointer hides | shipped; loop after the last page and the full-screen preferences 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 | From 0e580f7672dc2b6a7096320a69520b4aa9359021 Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:59:58 +0800 Subject: [PATCH 07/13] M3: full screen tests for display times, bounded and hostile durations, unknown styles, reduced motion, Escape and every key Review on #5316. No behaviour change: the presenter already cut transitions when animations are off (by default following the system), bounded /D to 10 s and /Dur to a day, ignored non-finite or negative values, read an unknown /S as Replace (ISO 32000-2 Table 164) and stopped with full screen. These tests pin each of those down, and every key and click full screen answers to. The Wipe test now counts the transition's frames from its first painted strip, so frames spent waiting for the raster don't count toward it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas/presenter.rs | 13 +- crates/ui-egui/src/canvas/tests.rs | 177 ++++++++++++++++++++++++- crates/ui-egui/tests/it/view.rs | 173 +++++++++++++++++++++++- 3 files changed, 355 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index b130a1b9e3..42bf7a2481 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -3,9 +3,13 @@ //! and the pointer hides while it stays still. //! //! Transitions play in single-page view without view rotation, from the outgoing page's raster -//! to the incoming one's once it has rendered; spreads, a rotated view and the Animation effects -//! preference set to off change pages at once. Display times count from the end of the -//! transition and stop at the last page. +//! to the incoming one's once it has rendered; spreads, a rotated view and animations turned off +//! (the Animation effects preference, which by default follows the system's request for no +//! animations) change pages at once. A style the spec doesn't name is its default, Replace, a +//! cut. Display times count from the end of the transition and stop at the last page; the keys +//! and clicks still turn pages meanwhile (§12.4.4.1, NOTE 1), and leaving full screen stops them. +//! The file's numbers are bounded: a transition plays for at most `MAX_TRANSITION` seconds, and a +//! display time waits at most `MAX_DISPLAY`. use egui::{Color32, CornerRadius, Painter, Pos2, Rect, TextureHandle, pos2}; use pdfcraft_render::transition::{self, Effect, Piece}; @@ -16,7 +20,8 @@ use super::{DocView, PageLayout}; /// Seconds the pointer stays still before full screen hides it. pub(crate) const HIDE_POINTER_AFTER: f64 = 3.0; /// Bounds on a document's numbers, which are untrusted: the longest transition played and the -/// longest display time waited for. +/// longest display time waited for (a day: a timer can't hold the largest number a file can +/// write). const MAX_TRANSITION: f64 = 10.0; const MAX_DISPLAY: f64 = 24.0 * 3600.0; diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index c10f3a35be..57da3e7da8 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2006,9 +2006,15 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale() /// Full screen's presenter for one frame of `ctx`, at time `now`: whether the frame asked to be /// drawn again at once. fn presenter_frame(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64) -> bool { + presenter_frame_animating(ctx, v, info, now, true) +} + +/// [`presenter_frame`] with the Animation effects preference as given (`false` also when the +/// system asks for no animations). +fn presenter_frame_animating(ctx: &egui::Context, v: &mut DocView, info: &DocInfo, now: f64, animates: bool) -> bool { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { let painter = ui.ctx().layer_painter(egui::LayerId::background()); - presenter::frame(v, info, &painter, ui.ctx(), now, true, None); + presenter::frame(v, info, &painter, ui.ctx(), now, animates, None); }); // No renderer here to upload textures: drop the frame's texture changes. out.textures_delta.clear(); @@ -2184,3 +2190,172 @@ fn a_display_time_in_two_page_view_waits_for_the_whole_spread() { presenter_frame(&ctx, &mut v, &info, 6.6); assert_eq!(v.current, 2, "its second on screen is up: the next spread"); } + +/// Page 1 of `pages` 300×400 pt pages, with `transitions`, in full screen (single page), its +/// raster in. The other pages get theirs with [`give_raster`]. +fn presenting_view(pages: usize, transitions: Vec, ctx: &egui::Context) -> (DocView, DocInfo) { + let page = pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 }; + let info = DocInfo { pages: vec![page; pages], transitions, ..Default::default() }; + let mut v = DocView::new(DocId(1), &info, ViewDefaults { layout: PageLayout::Single, continuous: false, ..Default::default() }); + v.enter_presentation(); + give_raster(ctx, &mut v, 0); + (v, info) +} + +/// Page `page`'s raster has arrived (a blank one). +fn give_raster(ctx: &egui::Context, v: &mut DocView, page: usize) { + let tex = ctx.load_texture("page", egui::ColorImage::new([4, 4], vec![Color32::WHITE; 16]), Default::default()); + v.pages.insert(page, super::cache::PageTex { tag: 0, tex }); +} + +/// Turn to `page` as a key would, with its raster in and its place on screen. +fn turn_to(ctx: &egui::Context, v: &mut DocView, page: usize) { + give_raster(ctx, v, page); + v.current = page; + v.screen_rects = vec![(page, Rect::from_min_size(Pos2::ZERO, egui::vec2(300.0, 400.0)))]; +} + +/// A page's `/Trans` (style and `/D`) and `/Dur`, the rest as the spec's defaults. +fn trans(page: usize, style: Option, duration: f32, display: Option) -> pdfcraft_render::PageTransition { + pdfcraft_render::PageTransition { page, style, duration, horizontal: true, inward: true, direction: Some(0), scale: 1.0, opaque: false, display } +} + +/// A page with a transition but no display time (ISO 32000-2 Table 31, `Dur`: "By default, the +/// viewer shall not advance automatically") stays until the user turns it, however long it shows. +#[test] +fn a_page_without_a_display_time_never_advances_by_itself() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, Some(TransitionStyle::Wipe), 1.0, None)], &ctx); + for now in [0.0, 1.0, 60.0, 86_400.0, 1.0e7] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "no /Dur: the page waits for the presenter"); +} + +/// A display time of 0 seconds advances as soon as the page is on screen: zero is a number the +/// spec allows, the time a page "shall be displayed" at most. +#[test] +fn a_display_time_of_zero_advances_once_the_page_is_on_screen() { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(0.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + assert_eq!(v.current, 1, "page 1's zero seconds are up at once"); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert_eq!(v.current, 1, "page 2 has no display time"); +} + +/// A display time from the file that is negative, not a number or endless is no display time, or +/// at most a day: it neither turns the page at once nor reaches a timer that can't hold it. +#[test] +fn a_negative_or_endless_display_time_is_no_display_time() { + for display in [-1.0, -0.001, f32::NAN, f32::NEG_INFINITY, f32::INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + for now in [0.0, 0.1, 5.0, 3600.0] { + presenter_frame(&ctx, &mut v, &info, now); + } + assert_eq!(v.current, 0, "/Dur {display}"); + } +} + +/// A display time longer than a day is cut to a day; the largest number the file can hold would +/// otherwise be past what a timer can wait for. +#[test] +fn a_display_time_longer_than_a_day_is_cut_to_a_day() { + for display in [1.0e9, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(0, None, 1.0, Some(display))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 86_399.0); + assert_eq!(v.current, 0, "/Dur {display}: not yet a day"); + presenter_frame(&ctx, &mut v, &info, 86_400.5); + assert_eq!(v.current, 1, "/Dur {display}: a day is up"); + } +} + +/// A transition the file says lasts a day (or longer) ends within ten seconds: it can't hold the +/// screen. +#[test] +fn a_transition_of_a_day_ends_within_ten_seconds() { + use pdfcraft_render::TransitionStyle; + for duration in [86_400.0, f32::MAX] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + presenter_frame(&ctx, &mut v, &info, 5.0); + assert!(v.presenting_a_transition(), "/D {duration}: playing"); + presenter_frame(&ctx, &mut v, &info, 10.2); + assert!(!v.presenting_a_transition(), "/D {duration}: over after ten seconds"); + } +} + +/// A transition of no length, a negative one or one that is not a number changes the page at once +/// (a negative one would otherwise never end). +#[test] +fn a_transition_of_zero_or_negative_length_cuts() { + use pdfcraft_render::TransitionStyle; + for duration in [0.0, -1.0, f32::NAN, f32::NEG_INFINITY] { + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), duration, None)], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "/D {duration}: cut"); + assert_eq!(v.current, 1); + } +} + +/// A style the spec doesn't name is read as its default R, Replace (ISO 32000-2 Table 164: "The +/// new page simply replaces the old one with no special transition effect"): the page changes at +/// once, and its display time still counts. +#[test] +fn an_unknown_transition_style_cuts_and_keeps_the_display_time() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let unknown = TransitionStyle::from_name("Spiral"); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(unknown), 5.0, Some(1.0))], &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 0.1); + assert!(!v.presenting_a_transition(), "no effect for an unknown style"); + presenter_frame(&ctx, &mut v, &info, 0.9); + assert_eq!(v.current, 1, "its display time is not up"); + presenter_frame(&ctx, &mut v, &info, 1.2); + assert_eq!(v.current, 2, "its display time is up"); +} + +/// With animations off (the preference, or the system asking for none), a transition is a cut and +/// the page's display time still counts from when it is on screen. +#[test] +fn without_animations_a_transition_cuts_and_the_display_time_counts() { + use pdfcraft_render::TransitionStyle; + let ctx = egui::Context::default(); + let (mut v, info) = presenting_view(3, vec![trans(1, Some(TransitionStyle::Wipe), 5.0, Some(1.0))], &ctx); + presenter_frame_animating(&ctx, &mut v, &info, 0.0, false); + turn_to(&ctx, &mut v, 1); + presenter_frame_animating(&ctx, &mut v, &info, 0.1, false); + assert!(!v.presenting_a_transition(), "cut"); + presenter_frame_animating(&ctx, &mut v, &info, 1.2, false); + assert_eq!(v.current, 2, "page 2's second is up"); +} + +/// A page turned by hand while a display time counts starts its own time afresh (ISO 32000-2 +/// §12.4.4.1, NOTE 1: "The user can advance the page manually before the specified time has +/// expired"); the time left on the page before is not carried over. +#[test] +fn a_page_turned_by_hand_starts_its_own_display_time() { + let ctx = egui::Context::default(); + let timed = (0..4).map(|p| trans(p, None, 1.0, Some(2.0))).collect(); + let (mut v, info) = presenting_view(4, timed, &ctx); + presenter_frame(&ctx, &mut v, &info, 0.0); + presenter_frame(&ctx, &mut v, &info, 1.5); + turn_to(&ctx, &mut v, 1); + presenter_frame(&ctx, &mut v, &info, 1.5); + presenter_frame(&ctx, &mut v, &info, 3.0); + assert_eq!(v.current, 1, "page 2 has shown 1.5 of its 2 seconds"); + presenter_frame(&ctx, &mut v, &info, 3.6); + assert_eq!(v.current, 2, "page 2's own 2 seconds are up"); +} diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index e4fdad63d3..b41ad5f2dc 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2343,10 +2343,15 @@ trailer << /Root 1 0 R >> /// The presentation document in full screen, frames a tenth of a second apart, every raster in. fn presenting(options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { + presenting_pdf(PRESENTATION, options) +} + +/// [`presenting`] with another document. +fn presenting_pdf(pdf: &'static [u8], options: &'static [(&'static str, &'static str)]) -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.1).build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); - app.open_bytes("slides.pdf", None, PRESENTATION.to_vec()).expect("opens"); + app.open_bytes("slides.pdf", None, pdf.to_vec()).expect("opens"); app.set_option("left", "closed").unwrap(); app.set_option("panel", "none").unwrap(); app.set_option("animation_effects", "on").unwrap(); @@ -2421,8 +2426,10 @@ fn full_screen_plays_a_page_transition_and_honours_the_display_time() { std::thread::sleep(std::time::Duration::from_millis(5)); } assert!(wipe_strip(&h), "the Wipe paints part of page 2 ({steps} frames)"); - let frames = steps + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); - assert!(frames >= 9, "the Wipe takes its second ({frames} frames of 0.1 s)"); + // Counted from the first painted strip, when page 2's raster is in and the Wipe is under way: + // the frames spent waiting for the raster don't count toward its second. + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames >= 8, "the Wipe takes its second ({frames} frames of 0.1 s after the first strip)"); assert_eq!(h.state().views[0].current, 1, "still page 2 when the transition ends"); h.run_steps(5); assert_eq!(h.state().views[0].current, 1, "half of its display time"); @@ -2624,3 +2631,163 @@ trailer << /Root 1 0 R >> assert_eq!(h.state().views[n].current, 0, "no page turned"); assert!(h.ctx.egui_wants_keyboard_input(), "the note is open to edit"); } + +/// With Animation effects following the system and the system asking for no animations (Windows' +/// Animation effects off, or another system's Reduce motion), full screen changes pages at once +/// and still keeps the page's display time; with the system not asking, the same transition plays. +#[test] +fn full_screen_cuts_transitions_when_the_system_asks_for_no_animations() { + for reduced in [true, false] { + let mut h = presenting(&[("animation_effects", "system")]); + h.state_mut().motion.reduced = reduced; + h.run_steps(1); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert_eq!(h.state().views[0].presenting_a_transition(), !reduced, "system asks for no animations: {reduced}"); + if reduced { + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "page 2's one-second display time still turns it ({frames} frames of 0.1 s)"); + } + } +} + +/// Escape leaves full screen while a transition plays and while a display time counts, and the +/// display time stops with it: the page reached stays. +#[test] +fn escape_leaves_full_screen_mid_transition_and_stops_the_display_time() { + for mid_transition in [true, false] { + let mut h = presenting(&[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + if !mid_transition { + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(3); + assert_eq!(h.state().views[0].current, 1, "page 2's display time is counting"); + } + h.key_press(Key::Escape); + h.run_steps(1); + assert!(!h.state().full_screen, "Escape leaves full screen (mid-transition: {mid_transition})"); + assert!(!h.state().views[0].presenting_a_transition()); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 1, "no page turns by itself after leaving (mid-transition: {mid_transition})"); + } +} + +/// While a page's display time counts, the keys still turn pages at once (ISO 32000-2 §12.4.4.1, +/// NOTE 1: "The user can advance the page manually before the specified time has expired"): +/// → goes on before the time is up, and ← back to a page with no display time, which then stays. +#[test] +fn full_screen_keys_take_over_from_a_display_time() { + let mut h = presenting(&[("animation_effects", "off")]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1, "page 2, with its one-second display time"); + h.key_press(Key::ArrowLeft); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 0, "← goes back before the time is up"); + h.run_steps(30); + assert_eq!(h.state().views[0].current, 0, "page 1 has no display time: it stays"); + h.key_press(Key::ArrowRight); + h.run_steps(2); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 2, "→ goes on before page 2's time is up"); +} + +/// Every way full screen moves: forward (→, Space, Enter, Page Down, a click), back (←, Backspace, +/// Shift+Enter, Page Up, a right-click, a Shift-click), first (Home, Shift+Ctrl+Page Up), last (End, +/// Shift+Ctrl+Page Down) and out (Escape, Ctrl+L). +#[test] +fn full_screen_every_key_and_click_goes_where_it_says() { + #[derive(Clone, Copy, Debug)] + enum Input { + Key(Key, Modifiers), + Click(egui::PointerButton, Modifiers), + } + use Input::{Click, Key as K}; + use egui::PointerButton::{Primary, Secondary}; + let both = Modifiers::COMMAND | Modifiers::SHIFT; + // (input, page before, page after); a page past the end stays where it is. + let moves = [ + (K(Key::ArrowRight, Modifiers::NONE), 0, 1), + (K(Key::Space, Modifiers::NONE), 0, 1), + (K(Key::Enter, Modifiers::NONE), 0, 1), + (K(Key::PageDown, Modifiers::NONE), 0, 1), + (Click(Primary, Modifiers::NONE), 0, 1), + (K(Key::ArrowLeft, Modifiers::NONE), 1, 0), + (K(Key::Backspace, Modifiers::NONE), 1, 0), + (K(Key::Enter, Modifiers::SHIFT), 1, 0), + (K(Key::PageUp, Modifiers::NONE), 1, 0), + (Click(Secondary, Modifiers::NONE), 1, 0), + (Click(Primary, Modifiers::SHIFT), 1, 0), + (K(Key::Home, Modifiers::NONE), 2, 0), + (K(Key::PageUp, both), 2, 0), + (K(Key::End, Modifiers::NONE), 0, 2), + (K(Key::PageDown, both), 0, 2), + ]; + // A document with no display times, so no page turns by itself meanwhile. + let mut h = harness(&[]); + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + assert!(h.state().full_screen); + for (input, from, to) in moves { + h.state_mut().views[0].go_to_page(from); + h.run_steps(4); + assert_eq!(h.state().views[0].current, from, "{input:?}: on page {}", from + 1); + match input { + K(key, modifiers) => { + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + } + Click(button, modifiers) => { + // Clear of page 1's link, which a click would follow instead. + let r = rect(&h, from).expect("the page is on screen"); + let at = egui::pos2(r.center().x, r.bottom() - 20.0); + h.hover_at(at); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: at, button, pressed, modifiers }); + } + h.run_steps(6); + } + } + assert_eq!(h.state().views[0].current, to, "{input:?} from page {}", from + 1); + assert!(h.state().full_screen, "{input:?} stays in full screen"); + } + for (key, modifiers) in [(Key::Escape, Modifiers::NONE), (Key::L, Modifiers::COMMAND)] { + if !h.state().full_screen { + h.key_press_modifiers(Modifiers::COMMAND, Key::L); + h.run_steps(6); + } + assert!(h.state().full_screen); + h.key_press_modifiers(modifiers, key); + h.run_steps(4); + assert!(!h.state().full_screen, "{key:?} leaves full screen"); + } +} + +/// A transition style the spec doesn't name (here `/Spiral`, with a five-second `/D`) is the +/// default, Replace (ISO 32000-2 Table 164, `S`: "Default value: R"): the page changes at once, and +/// its display time still turns it. +#[test] +fn full_screen_cuts_an_unknown_transition_style_and_keeps_its_display_time() { + const SPIRAL: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Trans << /S /Spiral /D 5 >> /Dur 1 >> endobj +5 0 obj << /Type /Page /Parent 2 0 R >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + let mut h = presenting_pdf(SPIRAL, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(!h.state().views[0].presenting_a_transition(), "no effect for an unknown style"); + settle(&mut h); + let frames = frames_until(&mut h, |a| a.views[0].current == 2); + assert!(frames <= 20, "its one-second display time turns it ({frames} frames of 0.1 s)"); +} From a20607da36917f29c6c33c47827cb5138aac09ec Mon Sep 17 00:00:00 2001 From: mtlcl410 <2368091+mtlcl410@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:44:11 +0800 Subject: [PATCH 08/13] M3: full screen takes no page input until the current page is on screen While a page's transition plays, or the page waits for its raster, the page under the pointer isn't the one shown, yet its links, fields, text and comments took clicks. Page input now waits until full screen shows the current page; turning pages (a click, a key) is handled apart and still works. A press made meanwhile gives nothing on the page an owner, so its release once the page is in takes nothing either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCfwzyRXj4VfQAVVdv --- crates/ui-egui/src/canvas.rs | 19 +++++-- crates/ui-egui/src/canvas/presenter.rs | 7 +++ crates/ui-egui/tests/it/view.rs | 68 ++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 61a266da88..a56ae244e2 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1680,6 +1680,13 @@ fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageTex } } +/// Full screen is turning to the current page, or waiting for it to render: the page under the +/// pointer isn't the one on screen, so its links, fields, text and comments take no input. +/// Turning the page (a click, a key) is handled apart and still works. +fn veiled(view: &DocView) -> bool { + view.presentation.is_some() && !view.presenter.settled_on(view.current) +} + /// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks /// and badges. fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { @@ -1713,8 +1720,9 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_ } crate::image_pick::drop_stale(doc, view); crate::image_pick::paint(painter, &xf, i, info, view); - // A cancelled press ends no selection: no markup or redaction is made from it. - if !view.press_cancelled { + // A cancelled press ends no selection: no markup or redaction is made from it, nor from a + // page full screen doesn't show yet. + if !view.press_cancelled && !veiled(view) { comments::page_after_text(resp, &pcx, view); if tool == QuickTool::Redact && can_modify { crate::redact_ui::after_text(resp, i, info, view); @@ -1885,8 +1893,11 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; - // A cancelled press takes nothing until the button is up. - let input = if view.press_cancelled { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; + // A cancelled press takes nothing until the button is up, nor does a page full screen + // doesn't show yet. (A press there gives nothing on the page an owner, so its release, once + // the page is in, takes nothing either.) + let input = + if view.press_cancelled || veiled(view) { PageInput { consumed: true, area_tool: false } } else { page_tool_input(ui, view, frame, &slot) }; let painter = ui.painter(); let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs index 42bf7a2481..d6c8461bfa 100644 --- a/crates/ui-egui/src/canvas/presenter.rs +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -52,6 +52,13 @@ impl Presenter { pub(crate) fn turning(&self) -> bool { self.turn.is_some() } + + /// Whether `page` is the page wholly in view: not while it comes in or waits for its raster, + /// nor in the frame that turns to it (this frame's [`frame`] hasn't caught up yet). While a + /// page turns, `shown` is `None`, so a transition under way needs no check of its own. + pub(crate) fn settled_on(&self, page: usize) -> bool { + self.shown.is_some_and(|(p, _)| p == page) + } } /// The transition and display time of page `page` (0-based), if it has either. diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index b41ad5f2dc..d1843c8760 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2598,6 +2598,74 @@ fn shift_ctrl_page_keys_and_arrows_go_to_the_first_and_last_page() { } } +/// Two pages, the second with a text field and a five-second Wipe. +const FIELD_BEHIND_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 5 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: while full screen turns to a page (its transition playing, or its raster on the +/// way), the page isn't the one on screen yet, so a click there opens none of its fields; once +/// the page is in, the same click does. +#[test] +fn full_screen_takes_no_page_input_until_the_page_is_on_screen() { + let mut h = presenting_pdf(FIELD_BEHIND_TRANSITION, &[]); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert_eq!(h.state().views[0].current, 1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(2); + assert!(h.state().views[0].presenting_a_transition(), "the Wipe is still playing"); + assert!(!h.ctx.egui_wants_keyboard_input(), "the field behind the transition isn't opened"); + frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + h.run_steps(1); + click_with(&mut h, on_field, Modifiers::NONE); + h.run_steps(4); + assert!(h.ctx.egui_wants_keyboard_input(), "once page 2 is in, the field opens"); +} + +/// [`FIELD_BEHIND_TRANSITION`] with a Wipe of 0.3 s, shorter than a click may be held. +const FIELD_BEHIND_SHORT_TRANSITION: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj +3 0 obj << /Type /Page /Parent 2 0 R >> endobj +4 0 obj << /Type /Page /Parent 2 0 R /Annots [5 0 R] /Trans << /S /Wipe /D 0.3 >> >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [50 100 250 140] /P 4 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// #5316 review: a press made while full screen turns to a page belongs to nothing on it, through +/// its release: let go on the field just after the transition ends, within a click's time, it +/// still opens nothing. +#[test] +fn full_screen_press_made_during_a_transition_opens_nothing_when_released() { + let mut h = presenting_pdf(FIELD_BEHIND_SHORT_TRANSITION, &[]); + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); + h.hover_at(on_field); + h.run_steps(1); + // Page 2 renders ahead: once nothing is left to render, its Wipe starts with the key. + settle(&mut h); + h.key_press(Key::ArrowRight); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "page 2 comes in with its Wipe"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + assert!(h.state().views[0].presenting_a_transition(), "pressed while the Wipe plays"); + let frames = frames_until(&mut h, |a| !a.views[0].presenting_a_transition()); + assert!(frames <= 4, "the Wipe ends within a click's time ({frames} frames of 0.1 s)"); + h.event(egui::Event::PointerButton { pos: on_field, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); + assert!(!h.ctx.egui_wants_keyboard_input(), "released on the field once page 2 is in: nothing opens"); +} + /// In full screen a double-click on a note opens it to edit, and turns no page: the pointer stays /// active "so that you can click links and open notes" (*Adobe Acrobat Help*, 2019, p. 65). #[test] From 97174c91ebe092a78ddf9ebc17991b285bec13fa Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 06:25:12 +0800 Subject: [PATCH 09/13] M3: the full-screen field click test waits until page 1 is shown Full screen veils a page until its raster is in, and a click on a veiled page turns it. The test clicked after six frames; on a slow CI runner the raster wasn't in yet, so the click on the field turned the page. It now waits (up to 600 frames) until DocView::presentation_settled says the page is wholly in view. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/ui-egui/src/canvas/xform.rs | 6 ++++++ crates/ui-egui/tests/it/view.rs | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 0f6249c8a8..71f397bdfb 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -6,6 +6,12 @@ impl DocView { self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| xf.rect) } + /// Whether the page full screen shows is wholly in view: no transition playing or waiting for + /// its raster, so a click on it reaches what is on it. Always true outside full screen. + pub fn presentation_settled(&self) -> bool { + self.presentation.is_none() || self.presenter.settled_on(self.current) + } + /// The document area on screen. pub fn viewport_rect(&self) -> Rect { self.viewport_screen diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 47047d08cf..4bc6c70194 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2761,6 +2761,15 @@ fn full_screen_click_on_a_field_fills_it_and_turns_no_page() { h.key_press_modifiers(Modifiers::COMMAND, Key::L); h.run_steps(6); let n = h.state().views.len() - 1; + // Until page 1's raster is in, full screen veils it and a click turns the page; on a slow + // machine that takes more frames than the six above. + for _ in 0..600 { + if h.state().views[n].presentation_settled() { + break; + } + h.run_steps(1); + } + assert!(h.state().views[n].presentation_settled(), "page 1 is shown"); let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); let on_field = egui::pos2(r.left() + r.width() * 0.5, r.top() + r.height() * (400.0 - 120.0) / 400.0); click_with(&mut h, on_field, Modifiers::NONE); From 9e08c8f78fb742ca289ce9509215de54d3ce12cc Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 01:11:38 +0800 Subject: [PATCH 10/13] M3: the full-screen note test waits for the page's raster (#379) Full screen takes no press on a page whose raster is not in yet; under load six frames were not always enough, and the double-click turned the page instead of opening the note. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA --- crates/ui-egui/tests/it/view.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index 4bc6c70194..5a8938de91 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2886,6 +2886,8 @@ trailer << /Root 1 0 R >> h.run_steps(8); h.key_press_modifiers(Modifiers::COMMAND, Key::L); h.run_steps(6); + // Until the page's raster is in: before it, full screen takes no press on the page. + settle(&mut h); let n = h.state().views.len() - 1; let r = h.state().views[n].page_screen_rect(0).expect("page 1 on screen"); let p = egui::pos2(r.left() + r.width() * 112.0 / 300.0, r.top() + r.height() * (400.0 - 112.0) / 400.0); From 9948e5c8a65f89ff16cd8db87402b44f92a337e3 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 01:47:50 +0800 Subject: [PATCH 11/13] Retrigger CI after the timing-dependent Fill & Sign test (#6066) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 0aded601af527596d5939e667f9e0d497f6cf9e4 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 02:17:57 +0800 Subject: [PATCH 12/13] Retrigger CI after an unrelated flaky test (automation batch concurrency) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA From 74e7b2b62fb7668413ee389ada7d8c15f2aac254 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Sun, 8 Nov 2026 02:48:00 +0800 Subject: [PATCH 13/13] Retrigger CI after the timing-dependent Fill & Sign test (#6066) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA