From 356195ba3388a98f51a4236da32315f39a955078 Mon Sep 17 00:00:00 2001 From: Naseq Date: Tue, 27 Oct 2026 17:53:57 +0430 Subject: [PATCH 1/3] Slide show: render the Presenter View's next-slide preview off the UI thread (#964) Turning the page with Presenter View on re-rendered the next-slide preview synchronously, single-threaded, on the UI thread. The preview now renders on a worker thread and arrives on a later frame; the web build (no threads), or a worker that dies, renders it inline. At most one render runs: paging faster than it asks for the newest preview once it is done, and a finished one no longer wanted is dropped. Job and texture are keyed by the presentation as well as the slide, so a preview rendered before an edit is rendered again. While a preview is on its way the box stays empty rather than saying "End of slide show". Rebased onto the show module split: show/presenter.rs. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show.rs | 11 +++- crates/ui-egui/src/show/presenter.rs | 63 +++++++++++++++--- crates/ui-egui/src/show/tests.rs | 97 ++++++++++++++++++++++++++++ 3 files changed, 160 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 342dfd2c..0bde860a 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -52,7 +52,13 @@ pub struct Show { tex: Option<(Key, TextureHandle)>, /// The shapes stacked above the playing videos, drawn over the video frames (#1005). overlay: Option<(Key, Vec, TextureHandle)>, - next_tex: Option<(usize, TextureHandle)>, + /// The Presenter View's next-slide preview: the presentation and slide it shows. + next_tex: Option<(std::sync::Arc, usize, TextureHandle)>, + /// The one next-slide preview being rendered on another thread (#964). + next_job: Option<(std::sync::Arc, usize, std::sync::mpsc::Receiver)>, + /// Preview renders started on another thread. + #[cfg(test)] + preview_spawns: usize, /// The next slide's first frame, rendered in the background while this one is shown so /// advancing only uploads it (#240). prefetch: Option, @@ -214,6 +220,9 @@ impl Show { tex: None, overlay: None, next_tex: None, + next_job: None, + #[cfg(test)] + preview_spawns: 0, prefetch: None, backdrop: None, blank: None, diff --git a/crates/ui-egui/src/show/presenter.rs b/crates/ui-egui/src/show/presenter.rs index 17f78fa5..f95f8b2c 100644 --- a/crates/ui-egui/src/show/presenter.rs +++ b/crates/ui-egui/src/show/presenter.rs @@ -75,27 +75,67 @@ pub(super) fn wall_clock() -> String { } } +/// The Presenter View's preview of slide `n`, `size` pixels. +fn render_preview(doc: &Presentation, n: usize, size: (u32, u32), threads: u16) -> egui::ColorImage { + let opts = RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), threads, ..Default::default() }; + crate::textures::to_color_image(&deckcraft_render::render_slide(doc, n, &opts), false) +} + /// Render the presenter window and set `keep` false when the user ends or closes the show. -pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, keep: &mut bool) { +pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &std::sync::Arc, now: f64, keep: &mut bool) { let cur = show.state.slide; let next = show.state.peek_next(doc); let cur_tex = show.tex.as_ref().map(|(_, t)| t.clone()); + // The preview wanted: slide `n` of this presentation (an edit replaces it). + let is = |d: &std::sync::Arc, k: usize, n: usize| std::sync::Arc::ptr_eq(d, doc) && k == n; if let Some(n) = next - && show.next_tex.as_ref().is_none_or(|(k, _)| *k != n) + && show.next_tex.as_ref().is_none_or(|(d, k, _)| !is(d, *k, n)) { // A very tall slide shape would otherwise ask for a picture taller than the GPU's limit. let next_size = crate::textures::fit_size( (480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), crate::textures::max_side(ctx), ); - let img = deckcraft_render::render_slide( - doc, - n, - &RenderOpts { scale: next_size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(next_size), ..Default::default() }, - ); - show.next_tex = Some((n, crate::textures::upload(ctx, "presenter-next", crate::textures::to_color_image(&img, false)))); + // Rendered on another thread, so turning the page doesn't wait for the preview (#964). One + // render at a time: paging faster than it renders asks for the newest preview once it is + // done, and a finished one no longer wanted is dropped. + if let Some((d, k, rx)) = show.next_job.take() { + match rx.try_recv() { + Ok(ci) if is(&d, k, n) => show.next_tex = Some((d, k, crate::textures::upload(ctx, "presenter-next", ci))), + Ok(_) => {} + Err(std::sync::mpsc::TryRecvError::Empty) => show.next_job = Some((d, k, rx)), + // The worker died: render this one here, as before, rather than start another. + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + show.next_tex = Some((doc.clone(), n, crate::textures::upload(ctx, "presenter-next", render_preview(doc, n, next_size, 0)))); + } + } + } + if show.next_job.is_none() && show.next_tex.as_ref().is_none_or(|(d, k, _)| !is(d, *k, n)) { + let (tx, rx) = std::sync::mpsc::channel(); + let worker_doc = doc.clone(); + // The web has no threads: render it here. + let spawned = !cfg!(target_arch = "wasm32") + && std::thread::Builder::new() + .name("show-next-preview".into()) + .spawn(move || { + let _ = tx.send(render_preview(&worker_doc, n, next_size, 1)); + }) + .is_ok(); + if spawned { + #[cfg(test)] + { + show.preview_spawns += 1; + } + show.next_job = Some((doc.clone(), n, rx)); + } else { + show.next_tex = Some((doc.clone(), n, crate::textures::upload(ctx, "presenter-next", render_preview(doc, n, next_size, 0)))); + } + } + if show.next_job.is_some() { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } } - let next_tex = show.next_tex.as_ref().filter(|(k, _)| Some(*k) == next).map(|(_, t)| t.clone()); + let next_tex = show.next_tex.as_ref().filter(|(d, k, _)| next.is_some_and(|n| is(d, *k, n))).map(|(_, _, t)| t.clone()); let no_notes = deckcraft_model::text::TextBody::default(); let notes = doc.slides.get(cur).map_or(&no_notes, |s| &s.notes); let clock = wall_clock(); @@ -212,7 +252,10 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese } None => { ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40)); - ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); + // A next slide whose preview is still on its way is not the end. + if next.is_none() { + ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170)); + } } } let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.0)); diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 8c515363..7d2e2aba 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -993,3 +993,100 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() { show.blank = Some(Color32::BLACK); assert_ne!(show.input_state(), base, "a blank screen has no slide click targets"); } + +/// #964: the next-slide preview renders on another thread; the presenter frame that asks for +/// it doesn't wait, and the preview arrives on a later frame. +#[test] +fn the_presenter_next_preview_arrives_without_blocking_the_frame() { + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.step(); + assert!(harness.state().1.next_job.is_some() || harness.state().1.next_tex.is_some()); + for _ in 0..200 { + if harness.state().1.next_tex.is_some() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + harness.step(); + } + assert_eq!(harness.state().1.next_tex.as_ref().map(|(_, n, _)| *n), Some(1)); + assert!(harness.state().1.next_job.is_none()); +} + +/// #964: paging quickly keeps one preview render running, and the preview that lands is the +/// newest one asked for. +#[test] +fn paging_quickly_renders_one_preview_at_a_time_and_lands_on_the_newest() { + let mut session = deckcraft_engine::Session::with_new(); + for _ in 0..7 { + session.execute("slide.new", &json!({})).unwrap(); + } + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + let mut turns = 0; + for _ in 0..6 { + let doc = harness.state().0.clone(); + let a = harness.state_mut().1.state.next(&doc); + harness.state_mut().1.apply(a, &doc, 0.0); + harness.step(); + turns += 1; + } + let want = harness.state().1.state.peek_next(&harness.state().0); + for _ in 0..400 { + if harness.state().1.next_tex.as_ref().map(|(_, k, _)| Some(*k)) == Some(want) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + harness.step(); + } + let st = &harness.state().1; + assert_eq!(st.next_tex.as_ref().map(|(_, k, _)| *k), want, "the newest preview lands"); + assert!(st.next_job.is_none()); + // A render starts only once the one before it has finished: never more than one per turn. + assert!(st.preview_spawns <= turns + 1, "{} renders for {turns} turns", st.preview_spawns); +} + +/// A preview rendered from a presentation that has since been edited (replaced) is dropped, +/// and the preview is rendered again from the new one. +#[test] +fn a_preview_of_an_edited_presentation_is_rendered_again() { + let mut session = deckcraft_engine::Session::with_new(); + session.execute("slide.new", &json!({})).unwrap(); + let doc = session.active().unwrap().doc.clone(); + let show = Show::new(&doc, 0, false, true); + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( + |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { + let now = ui.input(|i| i.time); + presenter_window(ui.ctx(), show, doc, now, keep); + }, + (doc, show, true), + ); + harness.step(); + let edited = std::sync::Arc::new((*harness.state().0).clone()); + harness.state_mut().0 = edited.clone(); + for _ in 0..400 { + if harness.state().1.next_tex.as_ref().is_some_and(|(d, _, _)| std::sync::Arc::ptr_eq(d, &edited)) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + harness.step(); + } + 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"); +} From feecee0a30c8dda21232991b803de64992e8f8d4 Mon Sep 17 00:00:00 2001 From: Naseq Date: Tue, 27 Oct 2026 18:13:44 +0430 Subject: [PATCH 2/3] Slide show tests: check the pending preview and the one-render gate directly (#964) A harness runs frames until no repaint is asked for, so the preview had already landed when its first assertion ran. One raw frame now shows the preview pending and one render started, and the paging test pins a pending job and checks a page turn starts no other render. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show/tests.rs | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 7d2e2aba..12ac1148 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1001,7 +1001,14 @@ fn the_presenter_next_preview_arrives_without_blocking_the_frame() { let mut session = deckcraft_engine::Session::with_new(); session.execute("slide.new", &json!({})).unwrap(); let doc = session.active().unwrap().doc.clone(); - let show = Show::new(&doc, 0, false, true); + // One frame on its own (a harness runs frames until the preview lands): it asks for the + // preview and returns without it. + let mut show = Show::new(&doc, 0, false, true); + let mut keep = true; + let ctx = egui::Context::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| presenter_window(ui.ctx(), &mut show, &doc, 0.0, &mut keep)); + assert!(show.next_job.is_some() && show.next_tex.is_none(), "the first frame leaves the preview pending"); + assert_eq!(show.preview_spawns, 1); let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state( |ui, (doc, show, keep): &mut (std::sync::Arc, Show, bool)| { let now = ui.input(|i| i.time); @@ -1009,8 +1016,6 @@ fn the_presenter_next_preview_arrives_without_blocking_the_frame() { }, (doc, show, true), ); - harness.step(); - assert!(harness.state().1.next_job.is_some() || harness.state().1.next_tex.is_some()); for _ in 0..200 { if harness.state().1.next_tex.is_some() { break; @@ -1020,6 +1025,7 @@ fn the_presenter_next_preview_arrives_without_blocking_the_frame() { } assert_eq!(harness.state().1.next_tex.as_ref().map(|(_, n, _)| *n), Some(1)); assert!(harness.state().1.next_job.is_none()); + assert_eq!(harness.state().1.preview_spawns, 1, "the pending render landed; none started over"); } /// #964: paging quickly keeps one preview render running, and the preview that lands is the @@ -1060,6 +1066,19 @@ fn paging_quickly_renders_one_preview_at_a_time_and_lands_on_the_newest() { assert!(st.next_job.is_none()); // A render starts only once the one before it has finished: never more than one per turn. assert!(st.preview_spawns <= turns + 1, "{} renders for {turns} turns", st.preview_spawns); + + // While a render is pending, turning the page starts no other. + let (_tx, rx) = std::sync::mpsc::channel(); + let doc = harness.state().0.clone(); + let spawns = harness.state().1.preview_spawns; + harness.state_mut().1.next_tex = None; + harness.state_mut().1.next_job = Some((doc.clone(), 0, rx)); + let a = harness.state_mut().1.state.prev(&doc); + harness.state_mut().1.apply(a, &doc, 0.0); + harness.step(); + let st = &harness.state().1; + assert_eq!(st.preview_spawns, spawns, "no render starts while one is pending"); + assert!(st.next_job.is_some()); } /// A preview rendered from a presentation that has since been edited (replaced) is dropped, From bcbdb76bfd4150d920ea4a0a8959d8c02e1ea5c2 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 30 Oct 2026 16:06:58 -0400 Subject: [PATCH 3/3] Slide show test: clear the textures of the lone presenter frame Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/show/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index 12ac1148..aedb0492 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1006,7 +1006,7 @@ fn the_presenter_next_preview_arrives_without_blocking_the_frame() { let mut show = Show::new(&doc, 0, false, true); let mut keep = true; let ctx = egui::Context::default(); - let _ = ctx.run_ui(egui::RawInput::default(), |ui| presenter_window(ui.ctx(), &mut show, &doc, 0.0, &mut keep)); + ctx.run_ui(egui::RawInput::default(), |ui| presenter_window(ui.ctx(), &mut show, &doc, 0.0, &mut keep)).textures_delta.clear(); assert!(show.next_job.is_some() && show.next_tex.is_none(), "the first frame leaves the preview pending"); assert_eq!(show.preview_spawns, 1); let mut harness = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state(