Merge #5316: M3: full screen plays page transitions and display times, and its keys and clicks go the right way (#379, #5315)

# Conflicts:
#	crates/ui-egui/src/canvas/tests.rs
This commit is contained in:
bflatastic committed 2026-11-09 18:26:15 +07:00
commit d40c626b6e
11 files changed
+1301 -28

No files matched your search

+59 -11
View File
@@ -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<SavedDisplay>,
/// Full screen's transitions, display times and pointer (see [`presenter`]).
pub(crate) presenter: presenter::Presenter,
pages: HashMap<usize, PageTex>,
/// Pages the renderer could not draw, with the reason (never re-requested).
errors: HashMap<usize, String>,
@@ -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<crate::fill_sign::SavedSig>,
key_scroll: f32,
key_side: f32,
@@ -1386,6 +1394,9 @@ struct PageOut {
page_action: Option<PageMenuAction>,
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<String>,
/// 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<bool>, link_followed: bool) {
fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option<bool>, 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<b
view.present_turn = Some((now + delay, !back));
}
if let Some((due, forward)) = view.present_turn {
if link_followed || view.selection.is_some() || view.presentation.is_none() {
if clicked_object || view.selection.is_some() || view.presentation.is_none() {
view.present_turn = None;
} else if now >= due {
view.present_turn = None;
@@ -2884,11 +2916,25 @@ fn shown_row(view: &DocView, rects: &[Rect]) -> Vec<usize> {
}
}
/// 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<bool> {
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;
+17 -8
View File
@@ -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.
+10
View File
@@ -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<Claim>; LAYERS.len()], tool: QuickTool) -> Option<Claim> {
LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool)))
+10 -2
View File
@@ -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);
+285
View File
@@ -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<Turn>,
/// 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<f64>,
}
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<Pos2>,
) -> 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);
}
}
+357
View File
@@ -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<pdfcraft_render::PageTransition>, 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<pdfcraft_render::TransitionStyle>, duration: f32, display: Option<f32>) -> 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] {
+6
View File
@@ -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
+8
View File
@@ -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.
+541
View File
@@ -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<u8> {
+3 -2
View File
@@ -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. |
+5 -5
View File
@@ -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"