diff --git a/apps/deckcraft-web/src/presenter.rs b/apps/deckcraft-web/src/presenter.rs index 1ec9a298c..40cafe849 100644 --- a/apps/deckcraft-web/src/presenter.rs +++ b/apps/deckcraft-web/src/presenter.rs @@ -251,6 +251,9 @@ impl Popup { if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) { data.next = previous.next.clone(); } + if data.slides.is_none() { + data.slides = previous.slides.clone(); + } } *target.borrow_mut() = Some(data); *received.borrow_mut() = js_sys::Date::now(); diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index 73e765ff7..c959829cd 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -144,6 +144,10 @@ impl SlideApp { let visible = show::viewport_visible(ctx); let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return }; let Some(show) = self.show.as_mut() else { return }; + if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) { + // A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866). + show.presenter_grid = false; + } if visible { // A visible logic-only pass (`run_logic`) carries the last full frame's input time. // Anchoring that stale time to `now` would move the hidden clock backwards if the diff --git a/crates/ui-egui/src/presenter.rs b/crates/ui-egui/src/presenter.rs index 95569778d..a58878830 100644 --- a/crates/ui-egui/src/presenter.rs +++ b/crates/ui-egui/src/presenter.rs @@ -49,10 +49,15 @@ pub struct Snapshot { pub paused: bool, pub notes_px: f32, pub blank: Option, + /// All slides is open in the presenter window (#1866); the audience keeps the current slide. pub grid: bool, pub zoom_armed: bool, pub current: Option, pub next: Option, + /// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta + /// means "unchanged since the last snapshot the window received". + #[serde(default)] + pub slides: Option>, } /// Images acknowledged by a successful transport send, never merely attempted images. @@ -60,6 +65,7 @@ pub struct Snapshot { pub struct DeltaCache { current: Option, next: Option, + slides: Option>, token: String, } impl DeltaCache { @@ -75,6 +81,12 @@ impl DeltaCache { let next_changed = fresh || self.next != snapshot.next; let current = snapshot.current.clone(); let next = snapshot.next.clone(); + let slides = snapshot.slides.clone(); + if !fresh && self.slides == slides { + snapshot.slides = None; + } else if snapshot.slides.is_none() { + snapshot.slides = Some(Vec::new()); + } if !current_changed { snapshot.current = None; } @@ -84,14 +96,28 @@ impl DeltaCache { send(snapshot, current_changed, next_changed)?; self.current = current; self.next = next; + self.slides = slides; self.token = token.to_owned(); Ok(()) } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Command { - Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool }, - Zoom { x: f32, y: f32 }, + Key { + name: String, + command: bool, + shift: bool, + alt: bool, + ctrl: bool, + }, + Zoom { + x: f32, + y: f32, + }, + /// A slide chosen in the presenter window's All slides grid (#1866). + Goto { + slide: usize, + }, End, } /// Implemented by the browser host. Native hosts retain the existing egui viewport. @@ -112,6 +138,8 @@ pub struct View { next: Option, previous_current: Option, previous_next: Option, + grid_source: Option>, + grid: Vec>, } impl View { /// Install the same interface and dynamic fallback fonts as SlideApp before layout. @@ -147,6 +175,10 @@ impl View { .collect::>() }); for (key, modifiers) in keys { + if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) { + // A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open. + continue; + } // Consume once before widgets see Space/Enter, avoiding a second button activation. ui.input_mut(|i| i.consume_key(modifiers, key)); commands.push(Command::Key { @@ -200,6 +232,11 @@ impl View { } }); ui.separator(); + if snapshot.grid { + // All slides opens here, as in the desktop Presenter View; the audience keeps the current slide. + self.grid_ui(ui, snapshot, &mut commands); + return commands; + } ui.columns(2, |columns| { let left = &mut columns[0]; if snapshot.ended { @@ -227,9 +264,6 @@ impl View { if snapshot.zoom_armed { left.label("Click the preview to zoom."); } - if snapshot.grid { - left.label("Choose a slide in the audience window, or press G to return."); - } let right = &mut columns[1]; right.label("Next"); if let Some(texture) = &self.next { @@ -260,6 +294,43 @@ impl View { commands } } +impl View { + fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec) { + let slides = snapshot.slides.as_deref().unwrap_or_default(); + if self.grid_source.as_deref() != Some(slides) { + self.grid_source = Some(slides.to_vec()); + self.grid = slides + .iter() + .enumerate() + .map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR))) + .collect(); + } + ui.heading("All slides"); + ui.label("Choose a slide to show it, or press G to return."); + if snapshot.total > crate::show::GRID_CAP { + ui.label(format!("First {} slides", crate::show::GRID_CAP)); + } + egui::ScrollArea::vertical().show(ui, |ui| { + ui.horizontal_wrapped(|ui| { + for (i, texture) in self.grid.iter().enumerate() { + let size = egui::vec2(160.0, 90.0); + let label = format!("{}", i + 1); + let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) }; + let response = match texture { + Some(t) => ui.add(egui::Button::image_and_text( + egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)), + label, + )), + None => ui.add_sized(size, egui::Button::new(label)), + }; + if response.clicked() { + commands.push(Command::Goto { slide: i }); + } + } + }); + }); + } +} fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 { size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0)) } @@ -282,6 +353,7 @@ mod tests { zoom_armed: false, current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }), next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }), + slides: None, } } @@ -418,6 +490,7 @@ mod tests { zoom_armed: false, current: None, next: None, + slides: None, }; let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( |ui, (view, snapshot, sent): &mut (View, Snapshot, Vec)| { @@ -437,6 +510,30 @@ mod tests { assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name())); } + #[test] + fn all_slides_opens_in_the_popup_and_picks_a_slide() { + use egui_kittest::kittest::Queryable; + let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] }; + let mut snapshot = snapshot(); + snapshot.total = 3; + snapshot.grid = true; + snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state( + |ui, (view, snapshot, sent): &mut (View, Snapshot, Vec)| { + sent.extend(view.ui(ui, snapshot)); + }, + (View::default(), snapshot, Vec::new()), + ); + h.run_steps(2); + assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)"); + h.get_by_label("3").click(); + h.run_steps(2); + assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail"); + } + #[test] fn previews_are_bounded_and_bad_dimensions_are_rejected() { let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED); diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 0cb2b458b..db7983c2b 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -18,6 +18,7 @@ mod presenter; mod render; mod ui; mod zoom; +pub(crate) use zoom::GRID_CAP; #[cfg(test)] use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars}; @@ -65,6 +66,8 @@ pub struct Show { preview_spawns: usize, remote_preview: Option, remote_next: Option<(usize, crate::presenter::Preview)>, + /// All slides thumbnails sent to the web presenter window while its grid is open (#1866). + remote_grid: Vec, remote_sent: f64, remote_opened: bool, /// The next slide's first frame, rendered in the background while this one is shown so @@ -237,6 +240,7 @@ impl Show { preview_spawns: 0, remote_preview: None, remote_next: None, + remote_grid: vec![], remote_sent: f64::NEG_INFINITY, remote_opened: false, prefetch: None, diff --git a/crates/ui-egui/src/show/tests.rs b/crates/ui-egui/src/show/tests.rs index b948efde2..a01989ac0 100644 --- a/crates/ui-egui/src/show/tests.rs +++ b/crates/ui-egui/src/show/tests.rs @@ -1132,6 +1132,8 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { opens: usize, closes: usize, commands: Vec, + last: Option, + gone: bool, } struct Mock(Rc>); impl Host for Mock { @@ -1140,12 +1142,14 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { Ok(()) } fn is_open(&self) -> bool { - true + !self.0.borrow().gone } fn commands(&mut self) -> Vec { std::mem::take(&mut self.0.borrow_mut().commands) } - fn update(&mut self, _: Snapshot) {} + fn update(&mut self, snapshot: Snapshot) { + self.0.borrow_mut().last = Some(snapshot); + } fn close(&mut self) { self.0.borrow_mut().closes += 1; } @@ -1179,6 +1183,33 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() { h.run_steps(2); assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}"); } + // #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide. + bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + let show = h.state().show.as_ref().unwrap(); + assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's"); + for _ in 0..20 { + h.step(); + std::thread::sleep(std::time::Duration::from_millis(15)); + } + let last = bridge.borrow().last.clone().unwrap(); + assert!(last.grid); + assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide"); + assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message"); + assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews"); + bridge.borrow_mut().commands.push(Command::Goto { slide: 2 }); + h.run_steps(2); + let show = h.state().show.as_ref().unwrap(); + assert!(!show.presenter_grid && !show.grid); + assert_eq!(show.state.slide, 2); + // Closing the popup with its grid open must not hold auto-advance for the rest of the show. + bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false }); + h.run_steps(2); + assert!(h.state().show.as_ref().unwrap().presenter_grid); + bridge.borrow_mut().gone = true; + h.run_steps(2); + assert!(!h.state().show.as_ref().unwrap().presenter_grid); + bridge.borrow_mut().gone = false; bridge.borrow_mut().commands.push(Command::End); h.run_steps(2); assert!(h.state().show.is_none()); diff --git a/crates/ui-egui/src/show/ui.rs b/crates/ui-egui/src/show/ui.rs index 226459755..d048fb93c 100644 --- a/crates/ui-egui/src/show/ui.rs +++ b/crates/ui-egui/src/show/ui.rs @@ -80,6 +80,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { && !show.reading && let Some(host) = app.services.presenter.as_mut() { + if show.remote_opened && show.presenter_grid && !host.is_open() { + // A grid left open in the closed popup would hold auto-advance for the rest of the show. + show.presenter_grid = false; + } if !show.remote_opened { show.remote_opened = true; if let Err(message) = host.open() { @@ -96,9 +100,18 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { crate::presenter::Command::Key { name, command, shift, alt, ctrl } => { if let Some(key) = egui::Key::from_name(&name) { let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl }; - out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0; + // The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866). + out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0; } } + crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => { + show.presenter_grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + let action = show.state.goto(&doc, slide); + out.keep &= show.apply(action, &doc, now); + } _ => {} } } @@ -143,6 +156,20 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { ); show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image)); } + if !show.presenter_grid { + show.remote_grid.clear(); + } else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() { + // A few thumbnails per update keep the audience window responsive on the web. + for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) { + // At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit. + let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0)); + let scale = REMOTE_THUMB / w.max(h); + let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1)); + let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() }); + let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)); + show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] })); + } + } host.update(crate::presenter::Snapshot { slide: show.state.slide, total: doc.slides.len(), @@ -152,10 +179,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { paused: show.timer.paused(), notes_px: show.notes_px, blank: show.blank_name().map(str::to_owned), - grid: show.grid, + grid: show.presenter_grid, zoom_armed: show.zoom_arm, - current: show.remote_preview.clone(), - next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()), + // The grid replaces both previews in the popup; leaving them out keeps one message small. + current: show.remote_preview.clone().filter(|_| !show.presenter_grid), + next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()), + slides: show.presenter_grid.then(|| show.remote_grid.clone()), }); show.remote_sent = now; } @@ -164,6 +193,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { finish(app, show, &f, out.keep); } +/// Longest side of an All slides thumbnail sent to the web presenter window (#1866). +const REMOTE_THUMB: f64 = 96.0; + fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) { if show.started { let full = ui.max_rect(); diff --git a/crates/ui-egui/src/show/zoom.rs b/crates/ui-egui/src/show/zoom.rs index 9c45fd4a2..da6104905 100644 --- a/crates/ui-egui/src/show/zoom.rs +++ b/crates/ui-egui/src/show/zoom.rs @@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0; /// PowerPoint's slide show magnifier goes up to 400%. const WHEEL_ZOOM_MAX: f32 = 4.0; -pub(super) const GRID_CAP: usize = 48; +pub(crate) const GRID_CAP: usize = 48; const TOOL_BAR_W: f32 = 520.0;