diff --git a/ROADMAP.md b/ROADMAP.md index b04c4b69..f7dc29ff 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI ``, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table | @@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged | diff --git a/crates/anim/src/show.rs b/crates/anim/src/show.rs index eb039e3d..15fb8ce9 100644 --- a/crates/anim/src/show.rs +++ b/crates/anim/src/show.rs @@ -58,6 +58,9 @@ pub struct ShowState { pub playing: bool, /// On the end screen. pub ended: bool, + /// The current slide was reached by going back (← / Backspace), so the show plays the + /// transition of the slide it left, in reverse, as PowerPoint does (#1531). + pub backward: bool, /// The show has entered a slide (so `slide` and `step` are meaningful and can be saved). active: bool, /// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks. @@ -208,6 +211,7 @@ impl ShowState { self.active = true; self.slide = i; self.ended = false; + self.backward = false; // Time spent elsewhere never counts for this slide's triggered animations. self.last_now = None; let steps = ShowState::steps(pres, i); @@ -309,6 +313,7 @@ impl ShowState { } self.step = ShowState::steps(pres, self.slide); self.playing = false; + self.backward = false; return ShowAction::Slide(self.slide); } // #844: undoing the automatic first step left the slide as before it appeared, and the show @@ -337,6 +342,7 @@ impl ShowState { self.step = ShowState::steps(pres, i); self.playing = false; } + self.backward = true; ShowAction::Slide(i) } None => ShowAction::None, diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index 043fd785..c98ac22b 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -763,6 +763,26 @@ fn show_skips_hidden_slides() { assert_eq!(s.goto(&p, 99), ShowAction::None); } +#[test] +fn show_remembers_a_slide_was_reached_by_going_back() { + // #1531: the UI plays the left slide's transition reversed only for Back. + let p = pres(3); + let mut s = ShowState::new(&p); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + assert!(!s.backward); + assert_eq!(s.prev(&p), ShowAction::Slide(0)); + assert!(s.backward); + assert_eq!(s.next(&p), ShowAction::Slide(1)); + assert!(!s.backward); + assert_eq!(s.goto(&p, 0), ShowAction::Slide(0)); + assert!(!s.backward, "a jump is not Back"); + // Back from the end screen shows the last slide again, without a transition to reverse. + assert_eq!(s.goto(&p, 2), ShowAction::Slide(2)); + assert_eq!(s.next(&p), ShowAction::End); + assert_eq!(s.prev(&p), ShowAction::Slide(2)); + assert!(!s.backward); +} + #[test] fn show_loops() { let mut p = pres(3); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 342dfd2c..53d5bbd6 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -27,6 +27,8 @@ use presenter::{NOTES_MAX, NOTES_MIN, NOTES_STEP, format_elapsed, presenter_note #[cfg(test)] use render::{below_playing_video, next_rect, render_frame, start_prefetch}; use render::{render_backdrop, render_threads}; +#[cfg(test)] +use ui::transition_layers; pub use ui::ui; use zoom::Zoom; #[cfg(test)] @@ -156,6 +158,9 @@ struct Transition { kind: String, option: String, dur: f64, + /// Going back: the transition of the slide left behind plays from its end to its start, so + /// the old slide takes the new slide's part (#1531). + reverse: bool, } /// A copy of the show's clock origins for regressions that must not depend on wall time. @@ -450,12 +455,6 @@ impl Show { if self.rehearse { self.timings.push((self.timeline.as_ref().map(|t| t.0).unwrap_or(i), now - self.slide_start)); } - let (kind, option, dur) = doc - .slides - .get(i) - .and_then(|s| s.transition.as_ref()) - .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) - .unwrap_or(("none".into(), String::new(), 0.0)); // Take the old slide's texture rather than cloning its handle: the new slide is // otherwise rendered into the same texture, and the transition blends the new // slide with itself (#33). @@ -463,8 +462,20 @@ impl Show { if from != Some(i) { self.last_viewed = from; } + // Going back plays the transition the slide left behind came in with, reversed + // (#1531); a queued Back that a later step overtook keeps the forward one. + let back = self.state.backward && self.state.slide == i && from.is_some_and(|f| f != i); + let (kind, option, dur) = doc + .slides + .get(if back { from.unwrap_or(i) } else { i }) + .and_then(|s| s.transition.as_ref()) + .map(|t| (t.kind.clone(), t.option.clone(), t.duration_ms as f64 / 1000.0)) + .unwrap_or(("none".into(), String::new(), 0.0)); + // Morph needs no reversing: a Morph from the old slide to the new one is the + // reversed Morph. + let reverse = back && kind != "morph"; self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { - Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur }) + Some(Transition { old, from, morph: None, parts: None, start: now, kind, option, dur, reverse }) } else { None }; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 8c515363..082e400d 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -443,6 +443,62 @@ fn a_transition_keeps_the_old_slide_in_its_own_texture() { assert!(show.tex.is_none(), "the new slide gets a texture of its own instead of overwriting the old one"); } +#[test] +fn going_back_plays_the_left_slides_transition_reversed() { + // #1531: Back from B to A played A's own entrance transition. PowerPoint plays the one B came + // in with, backwards. + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.set", &json!({"kind": "fade", "index": 0})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + let tex = |slide| { + let t = crate::textures::upload(&ctx, "show", egui::ColorImage::filled([1, 1], Color32::RED)); + Some((Key { slide, step: 0, size: (1, 1), doc: 0, hidden: vec![], gif: 0, plays: vec![] }, t)) + }; + let mut show = Show::new(&doc, 0, false, false); + show.tex = tex(0); + let a = show.state.next(&doc); + assert_eq!(a, ShowAction::Slide(1)); + assert!(show.apply(a, &doc, 0.0)); + let tr = show.trans.as_ref().expect("B's push plays"); + assert_eq!((tr.kind.as_str(), tr.reverse), ("push", false)); + + show.tex = tex(1); + let a = show.state.prev(&doc); + assert_eq!(a, ShowAction::Slide(0)); + assert!(show.apply(a, &doc, 1.0)); + let tr = show.trans.as_ref().expect("going back plays a transition"); + assert_eq!((tr.kind.as_str(), tr.reverse, tr.from), ("push", true, Some(1)), "B's push, reversed, not A's fade"); + + // Forward again: B's own transition, forward. + show.tex = tex(0); + let a = show.state.next(&doc); + assert!(show.apply(a, &doc, 2.0)); + let tr = show.trans.as_ref().expect("B's push plays"); + assert_eq!((tr.kind.as_str(), tr.reverse), ("push", false)); + + // A jump to an earlier slide is not Back: the slide's own transition plays. + show.tex = tex(1); + let a = show.state.goto(&doc, 0); + assert!(show.apply(a, &doc, 3.0)); + let tr = show.trans.as_ref().expect("A's fade plays"); + assert_eq!((tr.kind.as_str(), tr.reverse), ("fade", false)); +} + +#[test] +fn a_reversed_transition_starts_on_the_slide_left_and_ends_on_the_one_come_back_to() { + // #1531: running the frames from the end, with the old and new slides trading places. + for kind in ["fade", "push", "wipe", "cover"] { + let start = transition_layers(kind, "", true, 0.0, 16.0 / 9.0); + let top = start.last().expect("a frame"); + assert_eq!((top.source, top.alpha), (Source::Old, 1.0), "{kind}: the slide left is shown first"); + let end = transition_layers(kind, "", true, 1.0, 16.0 / 9.0); + assert!(end.iter().all(|l| l.source == Source::New), "{kind}: only the slide come back to is left: {:?}", end); + } +} + #[test] fn media_in_the_click_sequence_plays_with_its_step() { // #116: media set to play in the click sequence never started; only a click on it did. diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 7f850fac..5af891ac 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -9,8 +9,8 @@ use super::zoom::{ wheel_zoom_lines, }; use super::{ - Align2, BackKey, Click, Color32, CornerRadius, Key, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, SlideApp, - Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, + Align2, BackKey, Click, Color32, CornerRadius, Key, Layer, LinkJump, Pos2, Rect, RenderOpts, Sense, ShapeId, ShapeState, Show, ShowAction, + SlideApp, Source, Stroke, TextureHandle, Ui, json, pos2, show_fills_screen, state_for, theme, vec2, viewport_visible, }; use deckcraft_anim::{StepStamp, TriggerPlay}; use deckcraft_model::Presentation; @@ -391,7 +391,7 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te let aspect = doc.slide_size.width / doc.slide_size.height.max(1.0); let part = |k: usize| tr.parts.as_ref().and_then(|p| p.get(k).cloned().flatten()); let parts = [part(0), part(1), part(2), part(3)]; - for l in deckcraft_anim::transition_frame(&tr.kind, &tr.option, p, aspect) { + for l in transition_layers(&tr.kind, &tr.option, tr.reverse, p, aspect) { let old_or_black = |t: Option<&TextureHandle>| match t { Some(o) => quad_mesh(Some(o.id()), srect, &l, Color32::WHITE), None => quad_mesh(None, srect, &l, Color32::BLACK), @@ -421,6 +421,32 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te drew } +/// The layers of a transition at progress `p`. A reversed transition (going back, #1531) runs +/// from its end to its start, so the slide being left plays the part of the new slide and the +/// slide coming back the part of the old one. +pub(super) fn transition_layers(kind: &str, option: &str, reverse: bool, p: f64, aspect: f64) -> Vec { + if !reverse { + return deckcraft_anim::transition_frame(kind, option, p, aspect); + } + let mut layers = deckcraft_anim::transition_frame(kind, option, 1.0 - p, aspect); + for l in &mut layers { + l.source = swap_old_new(l.source); + } + layers +} + +fn swap_old_new(s: Source) -> Source { + match s { + Source::Old => Source::New, + Source::New => Source::Old, + Source::OldBack => Source::NewBack, + Source::NewBack => Source::OldBack, + Source::OldContent => Source::NewContent, + Source::NewContent => Source::OldContent, + Source::Black | Source::White => s, + } +} + fn draw_videos(app: &mut SlideApp, show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, tex: &Option) -> (Vec, bool) { let Fr { doc, full, srect, .. } = *f; if let Some(tex) = &tex {