diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 171460fcc1..0e846c8287 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -32,6 +32,7 @@ pub(super) mod page_cursor; mod page_input; pub(super) mod page_pointer; pub(super) mod page_router; +mod presenter; pub mod scroll_speed; mod selection; pub(super) mod signature_drag; @@ -567,6 +568,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, @@ -1044,6 +1047,7 @@ impl DocView { compare_marks: Vec::new(), interface: Default::default(), presentation: None, + presenter: Default::default(), pages: HashMap::new(), errors: HashMap::new(), cut_pages: Default::default(), @@ -1222,6 +1226,8 @@ impl DocView { fresh.interface = self.interface; // Still presenting, with the display to go back to on leaving. fresh.presentation = self.presentation.take(); + // 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(); @@ -1328,6 +1334,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, key_side: f32, @@ -1386,6 +1394,9 @@ struct PageOut { page_action: Option, paste_comments_at: Option<(usize, [f64; 2])>, 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, /// The current page or spread where it is drawn, every page of it: one off screen (the second /// page of a zoomed spread) has no screen transform, but still decides where the row ends. @@ -1723,9 +1734,12 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A cancelled press takes nothing until the button is up, though the page's menu still opens - // where a right-click is. - let input = if view.press_cancelled { - if let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { + // where a right-click is. A page full screen doesn't show yet takes nothing at all. (A press + // there gives nothing on the page an owner, so its release, once the page is in, takes nothing + // either.) + let veiled = page_input::veiled(view); + let input = if view.press_cancelled || veiled { + if !veiled && let Some(p) = frame.right_click_on(|p| xf.rect.contains(p)) { comments::anchor(&pcx, p, view); } PageInput { @@ -1802,6 +1816,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 { @@ -1927,6 +1942,7 @@ fn draw_pages( open_table, page_action: _, primary_down: _, + on_object: _, refused, row_on_screen: _, stamp_placed, @@ -1939,8 +1955,20 @@ 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). + let mut hide_pointer = false; + if view.presentation.is_some() { + 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); + } // 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 @@ -1997,7 +2025,8 @@ fn draw_pages( // The row as it is now: in continuous view the current page may have changed above. let row_on_screen = shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union); - resp.context_menu(|ui| { + let presenting = view.presentation.is_some(); + page_context_menu(&resp, presenting, |ui| { // Crop mode has no menu: a right-click on the picture being cropped, its box or its grips is // the crop's (#5835). if matches!(menu_for, Some(Owner::Picture { part: PicturePart::Crop(_), .. })) { @@ -2166,6 +2195,7 @@ fn draw_pages( page_action, paste_comments_at, primary_down: ptr.primary_down, + on_object, refused, row_on_screen, stamp_placed, @@ -2790,12 +2820,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 { @@ -2803,7 +2835,7 @@ fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option= due { view.present_turn = None; @@ -2884,11 +2916,25 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { } } +/// 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); + } +} + /// 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 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| { @@ -3264,6 +3310,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, @@ -3354,6 +3401,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { page_action, paste_comments_at, primary_down, + on_object, mut refused, row_on_screen, stamp_placed, @@ -3442,7 +3490,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 39be98bd97..6cfffa8d4b 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -423,21 +423,31 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s } } // Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl - // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434). + // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434; *Adobe Acrobat Help*, 2019, + // "Keys for navigating a PDF", p. 83). These take the key from the page turning and + // scrolling below. let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); - if key(Key::Home) || pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)) { + let to_start = pressed(shift_cmd(Key::ArrowUp)) || pressed(shift_cmd(Key::PageUp)); + let to_end = pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)); + if key(Key::Home) || to_start { view.go_to_page(0); } - if key(Key::End) || pressed(shift_cmd(Key::ArrowDown)) || pressed(shift_cmd(Key::PageDown)) { + if key(Key::End) || to_end { view.go_to_end(); } - // Full screen: Space and Enter go forward a page and Backspace back, as in Acrobat (the - // arrows and Page Up / Page Down already turn pages where nothing scrolls, below). + if to_end || to_start { + return; + } + let (command, shift) = ctx.input(|i| (i.modifiers.command, i.modifiers.shift)); + // 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) { + 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); } } @@ -446,7 +456,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s // than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page // once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← // turn it at once. - let command = ctx.input(|i| i.modifiers.command); let scrolls = view.continuous; let line = KEY_SCROLL_LINE * speed.factor(); // Only an up-to-date view counts: no jump pending. 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/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index f96ff4e91e..cf10ab48df 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -825,6 +825,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. +pub(super) 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. pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { @@ -869,8 +876,9 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P } 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); diff --git a/crates/ui-egui/src/canvas/presenter.rs b/crates/ui-egui/src/canvas/presenter.rs new file mode 100644 index 0000000000..d6c8461bfa --- /dev/null +++ b/crates/ui-egui/src/canvas/presenter.rs @@ -0,0 +1,285 @@ +//! 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 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}; +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 (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; + +/// 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() + } + + /// 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. +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, 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); + 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 }) + }); + // 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). 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; + } + + if let Some(turn) = view.presenter.turn.as_mut() { + // 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 => { + 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(); + } 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)); + } + } + } + } + } + + // 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 { + // In two-page view the last spread can't advance: then there is nothing to redraw. + let before = view.current; + view.step_page(true); + if view.current != before { + 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(_) => {} + } + 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)] +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/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b710261ba8..dab411bdf0 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -2724,6 +2724,363 @@ fn first_page_now_measures_the_pages_in_the_spread() { assert!(v.first_page_now(&info).is_none()); } +/// 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, animates, 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"); +} + +/// 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] +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(); + 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); + } + 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"); +} + +/// 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"); +} + +/// 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"); +} + +/// 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"); +} + #[test] fn image_handles_account_for_document_and_view_rotation() { for page_rotation in [0, 90, 180, 270] { 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/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index 6c10fc4feb..1e0e456073 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 de90573ea1..aac51037bd 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2631,6 +2631,547 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { 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> { + 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, pdf.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"); + // 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)"); + // 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"); + 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"); +} + +/// 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"); +} + +/// 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"); +} + +/// 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; + // 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); + 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}"); + } + } +} + +/// 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] +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); + // 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); + 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"); +} + +/// 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)"); +} + /// A one-page PDF whose content inflates past the renderer's per-stream limit (#623): a window /// of whitespace the interpreter can never complete, so the page is cut and says so. fn cut_content_pdf() -> Vec { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 20144d6cf5..f580b84c2a 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-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **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 @@ -33,7 +33,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 | split right or down (⌘\\, tab menu, a tab dropped on the page), one document or two; no four-pane spreadsheet split | split shipped; spreadsheet split 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 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 | @@ -86,6 +86,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-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once | | 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool | | 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. | diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 794ddff314..2d76d22c32 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" @@ -3275,7 +3275,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"