From ea9dad8ef988362f4a365d5cf44f1d525b00ad5b Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:04:05 +0430 Subject: [PATCH 1/2] Transitions: play transition preview from previous slide when Preview is clicked (#1654) - When Preview is clicked on the Transitions ribbon, initialize Show with start_transition_preview from the prior slide into the selected slide (or from black for slide 0). - If tr.old is unset for a non-part transition with tr.from, render the previous slide texture on the first frame so the transition blends cleanly. - Indicate active playback on the Preview button while previewing. - Regression tests for transitions from previous slide and from black on slide 0. --- crates/ui-egui/src/ribbon/transitions.rs | 13 +++++---- crates/ui-egui/src/show.rs | 21 +++++++++++++++ crates/ui-egui/src/show/tests.rs | 34 ++++++++++++++++++++++++ crates/ui-egui/src/show/ui.rs | 10 +++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs index 3f43ebd98..6986d828c 100644 --- a/crates/ui-egui/src/ribbon/transitions.rs +++ b/crates/ui-egui/src/ribbon/transitions.rs @@ -12,11 +12,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) { let cur = app.session.active().and_then(|d| d.current_slide().and_then(|s| s.transition.clone())); let kind = cur.as_ref().map(|t| t.kind.clone()).unwrap_or_else(|| "none".into()); group(ui, |ui| { - if big_button(ui, Icon::Preview, "Preview", true).clicked() { - let from = app.session.active().map(|d| d.selection.slide).unwrap_or(0); - app.start_show(from, true); - if let Some(s) = app.show.as_mut() { - s.preview_only = true; + let playing = app.show.as_ref().is_some_and(|s| s.preview_only); + let (resp, _) = widgets::big_button_ex(ui, Icon::Preview, "Preview", true, false, playing); + if resp.on_hover_text("Play this slide's transition").clicked() { + let to = app.session.active().map(|d| d.selection.slide).unwrap_or(0); + app.start_show(to, true); + let now = ui.input(|i| i.time); + if let (Some(s), Some(d)) = (app.show.as_mut(), app.session.active()) { + s.start_transition_preview(&d.doc, now); } } }); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 1896e3be7..7bd2edf7d 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -261,6 +261,27 @@ impl Show { } } + /// Play this slide's transition from the previous slide (or black for slide 0) as a preview (#1654). + pub fn start_transition_preview(&mut self, doc: &Presentation, now: f64) { + let i = self.state.slide; + let from = i.checked_sub(1); + 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)); + self.trans = if kind != "none" && dur > 0.0 && !doc.show.without_animation { + Some(Transition { old: None, from, morph: None, parts: None, start: now, kind, option, dur, reverse: false }) + } else { + None + }; + self.preview_only = true; + self.slide_start = now; + self.step_start = now; + self.step_end = now; + } + /// Identity of the show state a queued window-point event belongs to. pub(crate) fn input_state(&self) -> ShowInputState { let bits = |v: f32| if v.is_finite() { v.to_bits() } else { 0 }; diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index a1069a5bb..5e39bd18e 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1238,3 +1238,37 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { let tex = harness.state().1.next_tex.as_ref().map(|(d, k, _)| (std::sync::Arc::ptr_eq(d, &edited), *k)); assert_eq!(tex, Some((true, 1)), "the preview comes from the edited presentation"); } + +/// #1654: Preview on the Transitions ribbon plays the current slide's transition from the +/// previous slide into the current slide, and exits when finished. +#[test] +fn transition_preview_plays_from_the_previous_slide() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 1, true, false); + show.start_transition_preview(&doc, 10.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("transition preview is playing"); + assert_eq!(tr.kind, "push"); + assert_eq!(tr.from, Some(0), "enters from slide 0 into slide 1"); + assert_eq!(tr.dur, 1.0); + assert!(!tr.reverse); + assert_eq!(tr.start, 10.0); +} + +/// #1654: Transition preview for slide 0 plays with from = None (transitioning from black). +#[test] +fn transition_preview_for_first_slide_transitions_from_black() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("transition.set", &json!({"kind": "fade", "index": 0, "duration": 500})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, true, false); + show.start_transition_preview(&doc, 5.0); + assert!(show.preview_only); + let tr = show.trans.as_ref().expect("fade transition preview is playing"); + assert_eq!(tr.kind, "fade"); + assert_eq!(tr.from, None, "slide 0 transitions from black (None)"); + assert_eq!(tr.dur, 0.5); +} diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 71107f94f..321f8116d 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -388,6 +388,16 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te if tr.parts.is_none() && deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) { tr.parts = Some(part_textures(ctx, doc, tr.from, idx, size)); } + if tr.old.is_none() + && let Some(prev) = tr.from + && !deckcraft_anim::uses_slide_parts(&tr.kind, &tr.option) + { + let threads = + if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + let img = deckcraft_render::render_slide(doc, prev, &opts); + tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + } 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)]; From e0992c132568a26259d686c84c2c804deae201ee Mon Sep 17 00:00:00 2001 From: Naseq Date: Mon, 2 Nov 2026 15:25:31 +0430 Subject: [PATCH 2/2] Transitions: render cached previous slide with completed animation state - Use render_frame with timeline.steps() and t = 0.0 so the previous slide is rendered in its final completed state during transition preview. - Pass duration: 1000 integer ms in test to explicitly test the requested duration. --- crates/ui-egui/src/show/tests.rs | 2 +- crates/ui-egui/src/show/ui.rs | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 5e39bd18e..c7c34b171 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1245,7 +1245,7 @@ fn a_preview_of_an_edited_presentation_is_rendered_again() { fn transition_preview_plays_from_the_previous_slide() { let mut s = deckcraft_engine::Session::with_new(); s.execute("slide.new", &json!({})).unwrap(); - s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1.0})).unwrap(); + s.execute("transition.set", &json!({"kind": "push", "index": 1, "duration": 1000})).unwrap(); let doc = s.active().unwrap().doc.clone(); let mut show = Show::new(&doc, 1, true, false); show.start_transition_preview(&doc, 10.0); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 321f8116d..b02a181bc 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -10,7 +10,7 @@ use super::zoom::{ }; use super::{ 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, + ShowState, 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; @@ -394,9 +394,10 @@ fn draw_transition(show: &mut Show, f: &Fr, sl: &Sl, painter: &egui::Painter, te { let threads = if cfg!(target_arch = "wasm32") { 0 } else { std::thread::available_parallelism().map(|n| n.get().min(8) as u16).unwrap_or(0) }; - let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; - let img = deckcraft_render::render_slide(doc, prev, &opts); - tr.old = Some(crate::textures::upload(ctx, "trans-old", crate::textures::to_color_image(&img, false))); + let timeline = ShowState::timeline(doc, prev); + let step = timeline.steps(); + let ci = render_frame(doc, prev, timeline, step, 0.0, size, vec![], &[], threads, &[], &[], None); + tr.old = Some(crate::textures::upload(ctx, "trans-old", ci)); } 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());