mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-08 19:32:14 +00:00
Merge pull request #1872 from FredApps/fix/1866-web-presenter-all-slides
Show: All slides opens in the web Presenter View, as on desktop
This commit is contained in:
7 files changed
+183
-12
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -49,10 +49,15 @@ pub struct Snapshot {
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
/// 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<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
/// 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<Vec<Preview>>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
@@ -60,6 +65,7 @@ pub struct Snapshot {
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
slides: Option<Vec<Preview>>,
|
||||
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<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
grid_source: Option<Vec<Preview>>,
|
||||
grid: Vec<Option<egui::TextureHandle>>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
@@ -147,6 +175,10 @@ impl View {
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
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<Command>) {
|
||||
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<Command>)| {
|
||||
@@ -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<Command>)| {
|
||||
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);
|
||||
|
||||
@@ -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<crate::presenter::Preview>,
|
||||
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<crate::presenter::Preview>,
|
||||
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,
|
||||
|
||||
@@ -1132,6 +1132,8 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
last: Option<Snapshot>,
|
||||
gone: bool,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
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<Command> {
|
||||
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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user