diff --git a/ROADMAP.md b/ROADMAP.md index 71a0ddf8c..38b37a942 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -170,6 +170,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M4, page transitions read, §12.4.4):** `/Trans` and `/Dur` are read into `DocInfo::transitions` and listed by `doc_info`, the data a full-screen presentation needs to play transitions and auto-advance; playing and editing them stay planned. - **2026-10-14 (M4, page transitions set, §12.4.4):** `page_transition_set` (one undoable edit) writes or removes `/Trans` and `/Dur` on chosen pages with the values checked first, the counterpart of reading them into `DocInfo::transitions`; playing them and the dialog stay planned. +- **2026-10-14 (M12, transition geometry):** `pdfcraft_render::transition` gives the picture of any of the twelve page transitions at a moment of its run (which parts of the old and new page to paint, where, how opaque), in page fractions, tested for every style and direction; the full-screen presentation will paint from it once it plays transitions. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b0a2373ab..cc1e4af2e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -14,6 +14,7 @@ mod raster; mod reading; mod structure; pub mod text; +pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, diff --git a/crates/render/src/transition.rs b/crates/render/src/transition.rs new file mode 100644 index 000000000..15e9c828e --- /dev/null +++ b/crates/render/src/transition.rs @@ -0,0 +1,447 @@ +//! The geometry of a page transition (ISO 32000-2 §12.4.4, table 164) part-way through its run: +//! which parts of the outgoing and the incoming page a presentation paints, where, and how +//! opaque. Pure functions in page fractions (0 to 1 across and down the page box on screen), so +//! the painter only scales them; nothing here touches pixels or time. +//! +//! `Effect` is the transition as a presentation plays it (the `/Trans` entries that matter for +//! each style, with the spec's defaults), `frame` the picture at progress `t`. Every frame starts +//! with the outgoing page alone (`t = 0`) and ends with the incoming page alone (`t = 1`); the +//! pieces of a frame never leave the page box. + +/// A page transition as it is played: the style with the entries that style reads. Directions +/// are degrees as `/Di` gives them: 0 left to right, 90 bottom to top, 180 right to left, 270 top +/// to bottom, 315 top-left to bottom-right (Glitter; Wipe treats it the same way). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Effect { + /// Two halves split along a horizontal (`horizontal`) or vertical line, revealing the new page + /// from the edges inward (`inward`) or from the centre outward. + Split { horizontal: bool, inward: bool }, + /// Strips across (`horizontal`) or down the page open together, like Venetian blinds. + Blinds { horizontal: bool }, + /// A rectangle grows from the centre (`inward` false) or the edges close in on the centre. + Box { inward: bool }, + /// A line sweeps across the page in `direction`, revealing the new page behind it. + Wipe { direction: u16 }, + /// Random squares of the new page appear until it is whole. + Dissolve, + /// Like Dissolve, but the squares appear in a band that sweeps in `direction`. + Glitter { direction: u16 }, + /// No effect: the new page replaces the old one. + Replace, + /// The new page flies in from off-screen in `direction` (or from the centre, `None`), growing + /// from `scale`; `outward` flies the old page off instead. With `opaque` the moving page hides + /// what is under it (it always does here: the pieces overlap in paint order). + Fly { direction: Option, inward: bool, scale: f32 }, + /// The new page pushes the old one off the page in `direction`. + Push { direction: u16 }, + /// The new page slides in over the old one in `direction`. + Cover { direction: u16 }, + /// The old page slides off in `direction`, uncovering the new one beneath. + Uncover { direction: u16 }, + /// The new page fades in over the old one. + Fade, +} + +impl Effect { + /// The effect for a `/Trans` dictionary read as its parts: the `/S` name, `/Dm` as + /// `horizontal`, `/M` as `inward`, `/Di` as `direction` (`None` for `/None`), `/SS` and `/B`. + /// An unknown style is `Replace`, as the spec's default; an entry a style does not read is + /// ignored. + pub fn from_parts(style: &str, horizontal: bool, inward: bool, direction: Option, scale: f32) -> Self { + let dir = direction.filter(|d| matches!(d, 0 | 90 | 180 | 270 | 315)).unwrap_or(0); + match style { + "Split" => Self::Split { horizontal, inward }, + "Blinds" => Self::Blinds { horizontal }, + "Box" => Self::Box { inward }, + "Wipe" => Self::Wipe { direction: dir }, + "Dissolve" => Self::Dissolve, + "Glitter" => Self::Glitter { direction: dir }, + "Fly" => Self::Fly { + direction: direction.filter(|d| matches!(d, 0 | 90 | 180 | 270)), + inward, + scale: if scale.is_finite() && scale > 0.0 { scale.min(1.0) } else { 1.0 }, + }, + "Push" => Self::Push { direction: dir }, + "Cover" => Self::Cover { direction: dir }, + "Uncover" => Self::Uncover { direction: dir }, + "Fade" => Self::Fade, + _ => Self::Replace, + } + } +} + +/// A part of a page to paint: `src` is the part of that page, `dst` where it goes, both as +/// fractions `[x0, y0, x1, y1]` of the page box (y down), `alpha` its opacity. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Piece { + pub src: [f32; 4], + pub dst: [f32; 4], + pub alpha: f32, +} + +/// One moment of a transition: the pieces of the outgoing page, then of the incoming page, in +/// paint order (later pieces over earlier ones). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Frame { + pub outgoing: Vec, + pub incoming: Vec, +} + +/// Strips in Blinds, and cells per side in Dissolve and Glitter. +const BLINDS: usize = 8; +const GRID: usize = 8; +/// Strips that approximate a diagonal sweep. +const DIAGONAL_STRIPS: usize = 16; +const FULL: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; + +fn whole(alpha: f32) -> Piece { + Piece { src: FULL, dst: FULL, alpha } +} + +/// A piece of the page at `rect` (fractions), shown in place. +fn at(rect: [f32; 4]) -> Piece { + Piece { src: rect, dst: rect, alpha: 1.0 } +} + +/// The whole page shifted by `dx, dy` (fractions), clipped to the page box, or `None` when it is +/// entirely off the page. +fn shifted(dx: f32, dy: f32) -> Option { + let dst = [dx.max(0.0), dy.max(0.0), (1.0 + dx).min(1.0), (1.0 + dy).min(1.0)]; + if dst[2] - dst[0] <= 0.0 || dst[3] - dst[1] <= 0.0 { + return None; + } + let src = [dst[0] - dx, dst[1] - dy, dst[2] - dx, dst[3] - dy]; + Some(Piece { src, dst, alpha: 1.0 }) +} + +/// Where a page is at `k` of the way along `direction` (0 at the start, 1 at the end of a move +/// across the whole page), as an offset in fractions. +fn offset(direction: u16, k: f32) -> (f32, f32) { + match direction { + 90 => (0.0, -k), + 180 => (-k, 0.0), + 270 => (0.0, k), + _ => (k, 0.0), + } +} + +/// A small deterministic hash, so Dissolve and Glitter look random but repeat exactly. +fn hash(i: u32) -> u32 { + let mut x = i.wrapping_mul(0x9E37_79B9).wrapping_add(0x7F4A_7C15); + x ^= x >> 15; + x = x.wrapping_mul(0x2C1B_3C6D); + x ^= x >> 12; + x +} + +/// The cells of the dissolve grid in the order they appear: by `key`, ties by position. +fn cells_in_order(key: impl Fn(usize, usize) -> f32) -> Vec<[f32; 4]> { + let mut cells: Vec<(f32, usize, usize)> = (0..GRID * GRID).map(|i| (key(i % GRID, i / GRID), i / GRID, i % GRID)).collect(); + cells.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(&b.2))); + let side = 1.0 / GRID as f32; + cells.into_iter().map(|(_, row, col)| [col as f32 * side, row as f32 * side, (col + 1) as f32 * side, (row + 1) as f32 * side]).collect() +} + +/// The pieces of the incoming page at `t` for a diagonal sweep: a point is revealed once its +/// distance along the diagonal, `(x + y) / 2`, is within `t`, approximated by vertical strips. +fn diagonal(t: f32) -> Vec { + let w = 1.0 / DIAGONAL_STRIPS as f32; + (0..DIAGONAL_STRIPS) + .filter_map(|k| { + let x0 = k as f32 * w; + let h = (2.0 * t - (x0 + w / 2.0)).clamp(0.0, 1.0); + (h > 0.0).then(|| at([x0, 0.0, x0 + w, h])) + }) + .collect() +} + +/// The incoming page revealed by a straight sweep in `direction` at `t`. +fn swept(direction: u16, t: f32) -> Vec { + match direction { + 90 => vec![at([0.0, 1.0 - t, 1.0, 1.0])], + 180 => vec![at([1.0 - t, 0.0, 1.0, 1.0])], + 270 => vec![at([0.0, 0.0, 1.0, t])], + 315 => diagonal(t), + _ => vec![at([0.0, 0.0, t, 1.0])], + } +} + +/// The picture of `effect` at progress `t` (0: the outgoing page alone; 1: the incoming page +/// alone; anything else clamped). Pieces are in paint order and never leave the page box. +pub fn frame(effect: &Effect, t: f32) -> Frame { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 }; + if t <= 0.0 { + return Frame { outgoing: vec![whole(1.0)], incoming: Vec::new() }; + } + if t >= 1.0 { + return Frame { outgoing: Vec::new(), incoming: vec![whole(1.0)] }; + } + let over_old = |incoming: Vec| Frame { outgoing: vec![whole(1.0)], incoming }; + match *effect { + Effect::Replace => Frame { outgoing: Vec::new(), incoming: vec![whole(1.0)] }, + Effect::Fade => Frame { outgoing: vec![whole(1.0)], incoming: vec![whole(t)] }, + Effect::Wipe { direction } => over_old(swept(direction, t)), + Effect::Split { horizontal, inward } => { + let h = t / 2.0; + let bands = match (horizontal, inward) { + (true, true) => vec![at([0.0, 0.0, 1.0, h]), at([0.0, 1.0 - h, 1.0, 1.0])], + (true, false) => vec![at([0.0, 0.5 - h, 1.0, 0.5 + h])], + (false, true) => vec![at([0.0, 0.0, h, 1.0]), at([1.0 - h, 0.0, 1.0, 1.0])], + (false, false) => vec![at([0.5 - h, 0.0, 0.5 + h, 1.0])], + }; + over_old(bands) + } + Effect::Blinds { horizontal } => { + let w = 1.0 / BLINDS as f32; + let strips = (0..BLINDS) + .map(|k| { + let a = k as f32 * w; + if horizontal { at([0.0, a, 1.0, a + t * w]) } else { at([a, 0.0, a + t * w, 1.0]) } + }) + .collect(); + over_old(strips) + } + Effect::Box { inward } => { + let h = t / 2.0; + let pieces = if inward { + vec![at([0.0, 0.0, 1.0, h]), at([0.0, 1.0 - h, 1.0, 1.0]), at([0.0, h, h, 1.0 - h]), at([1.0 - h, h, 1.0, 1.0 - h])] + } else { + vec![at([0.5 - h, 0.5 - h, 0.5 + h, 0.5 + h])] + }; + over_old(pieces) + } + Effect::Dissolve => { + let cells = cells_in_order(|col, row| hash((row * GRID + col) as u32) as f32); + let shown = ((t * cells.len() as f32).round() as usize).min(cells.len()); + over_old(cells.into_iter().take(shown).map(at).collect()) + } + Effect::Glitter { direction } => { + // Ordered by how far along the sweep a cell lies, jittered so the band sparkles. + let along = |col: usize, row: usize| -> f32 { + let (x, y) = ((col as f32 + 0.5) / GRID as f32, (row as f32 + 0.5) / GRID as f32); + match direction { + 90 => 1.0 - y, + 180 => 1.0 - x, + 270 => y, + 315 => (x + y) / 2.0, + _ => x, + } + }; + let cells = cells_in_order(|col, row| along(col, row) + (hash((row * GRID + col) as u32 + 977) % 1000) as f32 / 4000.0); + let shown = ((t * cells.len() as f32).round() as usize).min(cells.len()); + over_old(cells.into_iter().take(shown).map(at).collect()) + } + Effect::Push { direction } => { + let (dx, dy) = offset(direction, t); + let (ux, uy) = offset(direction, 1.0); + Frame { outgoing: shifted(dx, dy).into_iter().collect(), incoming: shifted(dx - ux, dy - uy).into_iter().collect() } + } + Effect::Cover { direction } => { + let (dx, dy) = offset(direction, t); + let (ux, uy) = offset(direction, 1.0); + over_old(shifted(dx - ux, dy - uy).into_iter().collect()) + } + Effect::Uncover { direction } => { + let (dx, dy) = offset(direction, t); + Frame { outgoing: shifted(dx, dy).into_iter().collect(), incoming: vec![whole(1.0)] } + } + Effect::Fly { direction, inward, scale } => { + // The moving page: at `k` of its flight (0 off-screen or small, 1 whole and in place). + // `leaving`: the page moves on in `direction` (flying out); else it comes in from the + // side it would have left from (flying in). + let flight = |k: f32, leaving: bool| -> Option { + let s = scale + (1.0 - scale) * k; + let (dx, dy) = direction.map_or((0.0, 0.0), |d| { + let (ox, oy) = offset(d, 1.0 - k); + if leaving { (ox, oy) } else { (-ox, -oy) } + }); + // The page at scale `s`, centred, shifted by (dx, dy), clipped to the page box. + let half = s / 2.0; + let dst = [0.5 - half + dx, 0.5 - half + dy, 0.5 + half + dx, 0.5 + half + dy]; + let clip = [dst[0].max(0.0), dst[1].max(0.0), dst[2].min(1.0), dst[3].min(1.0)]; + if clip[2] - clip[0] <= 0.0 || clip[3] - clip[1] <= 0.0 || s <= 0.0 { + return None; + } + let src = [(clip[0] - dst[0]) / s, (clip[1] - dst[1]) / s, (clip[2] - dst[0]) / s, (clip[3] - dst[1]) / s]; + Some(Piece { src, dst: clip, alpha: 1.0 }) + }; + if inward { + Frame { outgoing: vec![whole(1.0)], incoming: flight(t, false).into_iter().collect() } + } else { + Frame { outgoing: flight(1.0 - t, true).into_iter().collect(), incoming: vec![whole(1.0)] } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn area(p: &Piece) -> f32 { + (p.dst[2] - p.dst[0]).max(0.0) * (p.dst[3] - p.dst[1]).max(0.0) + } + + fn inside(p: &Piece) -> bool { + p.dst.iter().chain(p.src.iter()).all(|v| (-1e-4..=1.0 + 1e-4).contains(v)) && p.dst[0] <= p.dst[2] && p.dst[1] <= p.dst[3] + } + + fn all_effects() -> Vec { + let mut v = vec![ + Effect::Replace, + Effect::Fade, + Effect::Dissolve, + Effect::Split { horizontal: true, inward: true }, + Effect::Split { horizontal: false, inward: false }, + Effect::Blinds { horizontal: true }, + Effect::Blinds { horizontal: false }, + Effect::Box { inward: true }, + Effect::Box { inward: false }, + ]; + for d in [0, 90, 180, 270, 315] { + v.push(Effect::Wipe { direction: d }); + v.push(Effect::Glitter { direction: d }); + } + for d in [0, 90, 180, 270] { + v.push(Effect::Push { direction: d }); + v.push(Effect::Cover { direction: d }); + v.push(Effect::Uncover { direction: d }); + v.push(Effect::Fly { direction: Some(d), inward: true, scale: 0.5 }); + v.push(Effect::Fly { direction: Some(d), inward: false, scale: 1.0 }); + } + v.push(Effect::Fly { direction: None, inward: true, scale: 0.25 }); + v + } + + #[test] + fn every_effect_starts_with_the_old_page_ends_with_the_new_and_stays_on_the_page() { + for e in all_effects() { + let start = frame(&e, 0.0); + assert_eq!((start.outgoing.len(), start.incoming.len()), (1, 0), "{e:?} at 0"); + assert_eq!(start.outgoing[0], whole(1.0)); + let end = frame(&e, 1.0); + assert_eq!((end.outgoing.len(), end.incoming.len()), (0, 1), "{e:?} at 1"); + assert_eq!(end.incoming[0], whole(1.0)); + assert_eq!(frame(&e, f32::NAN), end, "{e:?}: a non-number is the end"); + assert_eq!(frame(&e, -3.0), start); + for k in 1..20 { + let f = frame(&e, k as f32 / 20.0); + for p in f.outgoing.iter().chain(f.incoming.iter()) { + assert!(inside(p), "{e:?} at {k}/20: {p:?} leaves the page"); + assert!((0.0..=1.0).contains(&p.alpha)); + } + } + } + } + + #[test] + fn reveals_grow_with_time() { + // For every effect that reveals the new page in place, the revealed area never shrinks. + for e in all_effects().into_iter().filter(|e| { + !matches!(e, Effect::Replace | Effect::Fade | Effect::Push { .. } | Effect::Cover { .. } | Effect::Uncover { .. } | Effect::Fly { .. }) + }) { + let mut last = 0.0; + for k in 0..=20 { + let f = frame(&e, k as f32 / 20.0); + let a: f32 = f.incoming.iter().map(area).sum(); + assert!(a + 1e-4 >= last, "{e:?}: revealed area fell from {last} to {a} at {k}/20"); + last = a; + } + assert!((last - 1.0).abs() < 1e-4, "{e:?} ends whole: {last}"); + } + } + + #[test] + fn wipes_sweep_in_their_direction() { + let f = frame(&Effect::Wipe { direction: 0 }, 0.25); + assert_eq!(f.incoming, vec![at([0.0, 0.0, 0.25, 1.0])], "left to right"); + assert_eq!(frame(&Effect::Wipe { direction: 90 }, 0.25).incoming, vec![at([0.0, 0.75, 1.0, 1.0])], "bottom to top"); + assert_eq!(frame(&Effect::Wipe { direction: 180 }, 0.25).incoming, vec![at([0.75, 0.0, 1.0, 1.0])], "right to left"); + assert_eq!(frame(&Effect::Wipe { direction: 270 }, 0.25).incoming, vec![at([0.0, 0.0, 1.0, 0.25])], "top to bottom"); + // Diagonal: the top-left corner first, the bottom-right last. + let d = frame(&Effect::Wipe { direction: 315 }, 0.25); + assert!(!d.incoming.is_empty() && d.incoming[0].dst[0] == 0.0 && d.incoming[0].dst[3] > 0.0, "{:?}", d.incoming); + assert!(d.incoming.iter().all(|p| p.dst[0] < 0.5), "nothing past the middle yet: {:?}", d.incoming); + } + + #[test] + fn split_box_and_blinds_open_where_they_say() { + let h = frame(&Effect::Split { horizontal: true, inward: true }, 0.5).incoming; + assert_eq!(h, vec![at([0.0, 0.0, 1.0, 0.25]), at([0.0, 0.75, 1.0, 1.0])], "from the top and bottom edges"); + let v = frame(&Effect::Split { horizontal: false, inward: false }, 0.5).incoming; + assert_eq!(v, vec![at([0.25, 0.0, 0.75, 1.0])], "a vertical band from the centre"); + let b = frame(&Effect::Box { inward: false }, 0.5).incoming; + assert_eq!(b, vec![at([0.25, 0.25, 0.75, 0.75])]); + let bi = frame(&Effect::Box { inward: true }, 0.5).incoming; + assert_eq!(bi.len(), 4); + assert!((bi.iter().map(area).sum::() - 0.75).abs() < 1e-5, "a 0.5 hole is left in the middle"); + let blinds = frame(&Effect::Blinds { horizontal: true }, 0.5).incoming; + assert_eq!(blinds.len(), BLINDS); + assert_eq!(blinds[1], at([0.0, 0.125, 1.0, 0.1875]), "each strip opens from its top"); + } + + #[test] + fn dissolve_and_glitter_show_a_share_of_cells_in_a_fixed_order() { + let half = frame(&Effect::Dissolve, 0.5).incoming; + assert_eq!(half.len(), GRID * GRID / 2); + assert_eq!(frame(&Effect::Dissolve, 0.5).incoming, half, "the same every time"); + let quarter = frame(&Effect::Dissolve, 0.25).incoming; + assert_eq!(&half[..quarter.len()], &quarter[..], "cells only ever join"); + assert!(half.windows(2).any(|w| w[0].dst[1] != w[1].dst[1] || w[0].dst[0] + 0.125 != w[1].dst[0]), "not in reading order"); + // Glitter sweeps: at a third, every cell shown is in the left half for direction 0. + let g = frame(&Effect::Glitter { direction: 0 }, 0.3).incoming; + assert!(!g.is_empty() && g.iter().all(|p| p.dst[0] < 0.5), "{g:?}"); + let g = frame(&Effect::Glitter { direction: 270 }, 0.3).incoming; + assert!(g.iter().all(|p| p.dst[1] < 0.5), "top first: {g:?}"); + } + + #[test] + fn push_cover_uncover_and_fly_move_whole_pages() { + let f = frame(&Effect::Push { direction: 0 }, 0.25); + assert_eq!(f.outgoing, vec![Piece { src: [0.0, 0.0, 0.75, 1.0], dst: [0.25, 0.0, 1.0, 1.0], alpha: 1.0 }], "the old page moves right"); + assert_eq!( + f.incoming, + vec![Piece { src: [0.75, 0.0, 1.0, 1.0], dst: [0.0, 0.0, 0.25, 1.0], alpha: 1.0 }], + "the new page comes in from the left" + ); + let f = frame(&Effect::Push { direction: 270 }, 0.5); + assert_eq!(f.incoming[0].dst, [0.0, 0.0, 1.0, 0.5], "top to bottom"); + let c = frame(&Effect::Cover { direction: 180 }, 0.25); + assert_eq!( + (c.outgoing, c.incoming[0].dst), + (vec![whole(1.0)], [0.75, 0.0, 1.0, 1.0]), + "the old page stays, the new slides in from the right" + ); + let u = frame(&Effect::Uncover { direction: 90 }, 0.25); + assert_eq!((u.incoming, u.outgoing[0].dst), (vec![whole(1.0)], [0.0, 0.0, 1.0, 0.75]), "the old page slides up off the new one"); + // Fly in from the centre: a quarter-size page in the middle at the start, growing. + let f = frame(&Effect::Fly { direction: None, inward: true, scale: 0.25 }, 0.5); + let p = f.incoming[0]; + assert_eq!(p.src, FULL, "the whole page, small"); + assert!((p.dst[2] - p.dst[0] - 0.625).abs() < 1e-5 && (p.dst[0] - 0.1875).abs() < 1e-5, "{p:?}"); + // Fly in from the left: off-page at the start, so nothing to paint yet. + assert!(frame(&Effect::Fly { direction: Some(0), inward: true, scale: 1.0 }, 1e-3).incoming.first().is_some_and(|p| p.dst[2] < 0.01)); + // Fly out: the old page leaves to the right over the new page. + let f = frame(&Effect::Fly { direction: Some(0), inward: false, scale: 1.0 }, 0.5); + assert_eq!((f.incoming, f.outgoing[0].dst), (vec![whole(1.0)], [0.5, 0.0, 1.0, 1.0])); + } + + #[test] + fn effects_come_from_trans_parts_with_the_defaults() { + assert_eq!(Effect::from_parts("Wipe", true, true, Some(270), 1.0), Effect::Wipe { direction: 270 }); + assert_eq!( + Effect::from_parts("Wipe", true, true, Some(45), 1.0), + Effect::Wipe { direction: 0 }, + "a direction outside the five is the default" + ); + assert_eq!(Effect::from_parts("Split", false, false, Some(90), 1.0), Effect::Split { horizontal: false, inward: false }); + assert_eq!(Effect::from_parts("Fly", true, true, None, 0.5), Effect::Fly { direction: None, inward: true, scale: 0.5 }); + assert_eq!( + Effect::from_parts("Fly", true, false, Some(315), f32::NAN), + Effect::Fly { direction: None, inward: false, scale: 1.0 }, + "Fly has no diagonal; a bad scale is 1" + ); + assert_eq!(Effect::from_parts("Swirl", true, true, Some(0), 1.0), Effect::Replace); + assert_eq!(Effect::from_parts("R", true, true, Some(0), 1.0), Effect::Replace); + } +}