diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 9140c080..90504d3c 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -38,6 +38,8 @@ pub struct Show { blank: Option, /// See All Slides covers the show until a slide is chosen. pub grid: bool, + /// The Presenter View's own See All Slides: the grid replaces the slide and notes in that window only; the audience keeps the slide. + pub presenter_grid: bool, /// The next click on the slide zooms in there. pub zoom_arm: bool, zoom: Option, @@ -289,6 +291,7 @@ impl Show { backdrop: None, blank: None, grid: false, + presenter_grid: false, zoom_arm: false, zoom: None, thumbs: vec![], @@ -560,7 +563,8 @@ fn part_textures(ctx: &egui::Context, doc: &Presentation, from: Option, t /// Read the current viewport's show keys before its widgets can consume them. Both the /// audience window and Presenter View use this path, including animation steps and end-of-show. -fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) -> bool { +/// `presenter` is true for Presenter View, where G opens that window's own grid (#1130). +fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, presenter: bool) -> bool { let keys: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| { i.events .iter() @@ -571,10 +575,16 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) let mut actions = vec![]; for (k, m) in keys { use egui::Key::*; - if show.grid && !matches!(k, Escape | Minus) { + let presenter_grid = presenter && show.presenter_grid; + let grid_open = if presenter { show.presenter_grid } else { show.grid }; + if grid_open && !matches!(k, Escape | Minus) { // See All Slides takes no other keys until a slide is chosen. if k == G && plain(m) { - show.grid = false; + if presenter_grid { + show.presenter_grid = false; + } else { + show.grid = false; + } } else { continue; } @@ -582,7 +592,7 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) match k { Escape | Minus => { let cancelled = k == Escape && show.clear_goto(); - if !cancelled && !show.back_out() { + if !cancelled && !show.back_out(presenter) { keep = false; } } @@ -629,7 +639,7 @@ fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) B | Period => toggle_blank(show, Color32::BLACK), W | Comma => toggle_blank(show, Color32::WHITE), E => show.strokes.clear(), - G if plain(m) => show.grid = true, + G if plain(m) => show.open_grid(presenter), Z if plain(m) => show.zoom_key(), T if plain(m) => show.timer.toggle(now), R if plain(m) => show.timer.reset(now), @@ -676,7 +686,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } show.after_enter(&doc, now); } - let mut keep = keyboard(&ctx, &mut show, &doc, now); + let mut keep = keyboard(&ctx, &mut show, &doc, now, false); let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0)); if moved { show.last_move = now; @@ -794,7 +804,7 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let cur_step = show.state.step; let n_steps = show.timeline(&doc).steps(); let step_due = show.autoplay && cur_step < n_steps && idle > 0.3; - let holding = show.grid || show.zoom.is_some() || show.zoom_arm; + let holding = show.grid || show.presenter_grid || show.zoom.is_some() || show.zoom_arm; if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) { actions.push(show.state.next(&doc)); } else if show.preview_only && cur_step >= n_steps && idle > 0.8 { @@ -1495,6 +1505,15 @@ impl Show { } } + /// G opens See All Slides in the window it was pressed in: Presenter View's grid stays off the audience screen (#1130). + fn open_grid(&mut self, presenter: bool) { + if presenter { + self.presenter_grid = true; + } else { + self.grid = true; + } + } + /// Z, or the Zoom button: arm a click-to-zoom, or leave a zoom that is already on. fn zoom_key(&mut self) { if self.zoom.is_some() || self.zoom_arm { @@ -1505,9 +1524,12 @@ impl Show { } } - /// Esc closes See All Slides, then a zoom, and only then leaves the show. - fn back_out(&mut self) -> bool { - if self.grid { + /// Esc closes the See All Slides of the window it was pressed in, then a zoom, and only then leaves the show. + fn back_out(&mut self, presenter: bool) -> bool { + if presenter && self.presenter_grid { + self.presenter_grid = false; + true + } else if !presenter && self.grid { self.grid = false; true } else { @@ -1932,7 +1954,7 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no egui::ViewportId::from_hash_of("deckcraft_presenter"), egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]), |ui, _| { - if !keyboard(ui.ctx(), show, doc, now) { + if !keyboard(ui.ctx(), show, doc, now, true) { *keep = false; } ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); @@ -1974,8 +1996,8 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() { toggle_blank(show, Color32::WHITE); } - if button(ui, if show.grid { "All slides on" } else { "All slides" }).clicked() { - show.grid = !show.grid; + if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() { + show.presenter_grid = !show.presenter_grid; } if button(ui, show.zoom_label()).clicked() { show.zoom_key(); @@ -1983,6 +2005,23 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no }); ui.add_space(8.0); let avail = ui.available_rect_before_wrap(); + if show.presenter_grid { + // The grid fills this window; the audience keeps seeing the current slide. + paint_grid(ui.painter(), ui.ctx(), show, doc, avail); + let hit = ui.interact(avail, ui.id().with("presenter_grid"), Sense::click()); + if hit.clicked() + && let Some(p) = hit.interact_pointer_pos() + && let Some(n) = see_all_hit(avail, grid_indices(doc.slides.len()).len(), slide_aspect(doc), p) + && let Some(&i) = grid_indices(doc.slides.len()).get(n) + { + show.presenter_grid = false; + show.blank = None; + show.zoom = None; + show.zoom_arm = false; + actions.push(show.state.goto(doc, i)); + } + return; + } let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, avail.max.y)); let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), avail.max); let cr = fit(Rect::from_min_max(left.min, pos2(left.max.x, left.min.y + left.height() * 0.8)), doc); @@ -2039,6 +2078,8 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no }); if ui.input(|i| i.viewport().close_requested()) { show.presenter = false; + // A grid left open in the closed window would hold auto-advance for the rest of the show. + show.presenter_grid = false; } }, ); @@ -2115,6 +2156,40 @@ mod tests { assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden"); } + /// #1130: the Presenter View's All slides button opens the grid in that window, not on the audience screen. + #[test] + fn presenter_all_slides_does_not_cover_the_audience_screen() { + use egui_kittest::kittest::Queryable; + + 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.run_steps(2); + harness.get_by_label("All slides").click(); + harness.run_steps(2); + assert!(harness.state().1.presenter_grid, "the grid opens in the Presenter View"); + assert!(!harness.state().1.grid, "the audience screen keeps showing the slide"); + harness.key_press(egui::Key::Escape); + harness.run_steps(2); + assert!(!harness.state().1.presenter_grid, "Esc closes the Presenter View grid"); + assert!(harness.state().2, "and does not end the show"); + harness.key_press(egui::Key::G); + harness.run_steps(2); + assert!(harness.state().1.presenter_grid, "G in Presenter View opens its own grid"); + assert!(!harness.state().1.grid, "G in Presenter View leaves the audience screen alone"); + harness.key_press(egui::Key::G); + harness.run_steps(2); + assert!(!harness.state().1.presenter_grid, "G again closes it"); + } + /// Send real egui key events to the presenter UI, without the audience window's handler. #[test] fn presenter_navigation_uses_its_own_keyboard_events() { @@ -2412,6 +2487,24 @@ mod tests { assert!((back.0 - 0.5).abs() < 1e-3 && (back.1 - 0.5).abs() < 1e-3); } + #[test] + fn escape_only_closes_the_grid_of_its_own_window() { + let mut s = deckcraft_engine::Session::with_new(); + s.execute("slide.new", &json!({})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let mut show = Show::new(&doc, 0, false, true); + show.presenter_grid = true; + assert!(!show.back_out(false), "audience Esc leaves the show, the Presenter View grid stays"); + assert!(show.presenter_grid); + assert!(show.back_out(true)); + assert!(!show.presenter_grid); + show.grid = true; + assert!(!show.back_out(true), "Presenter View Esc does not close the audience grid"); + assert!(show.grid); + assert!(show.back_out(false)); + assert!(!show.grid); + } + #[test] fn escape_leaves_the_grid_then_the_zoom_and_blank_toggles() { let mut s = deckcraft_engine::Session::with_new(); @@ -2420,12 +2513,12 @@ mod tests { let mut show = Show::new(&doc, 0, false, true); show.grid = true; show.zoom = Some(Zoom::at(0.2, 0.3)); - assert!(show.back_out()); + assert!(show.back_out(false)); assert!(!show.grid); assert!(show.zoom.is_some(), "closing the grid leaves the zoom"); - assert!(show.back_out()); + assert!(show.back_out(false)); assert!(show.zoom.is_none()); - assert!(!show.back_out()); + assert!(!show.back_out(false)); show.zoom_key(); assert!(show.zoom_arm); show.zoom_key(); diff --git a/docs/slideshow-parity.md b/docs/slideshow-parity.md index 6a3f92b1..6c372888 100644 --- a/docs/slideshow-parity.md +++ b/docs/slideshow-parity.md @@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** major (community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, rehearsal and recording. Transition and animation playback are in @@ -28,7 +28,7 @@ ready** (estimated), **10–16 h** to parity. | Hyperlinks and action settings followed (slides, web, mail, End Show, Last Viewed, Custom Show) | yes | yes | Only `http(s)` and `mailto` open outside the show, by design (`safe_url`). A Custom Show action plays that show | | Media playback with controls, play across slides, full-screen video, Play/Pause/Stop in the click sequence | yes | yes | WMA/WMV not decodable. Click sequence since #116. Slide Show ▸ Show Media Controls (on by default) hides the playback bar. Seeks and loops preserve new-generation video frames and end-of-stream; queue regression and repeated seeks across H.264/HEVC/VP9/AV1 in `crates/media` | | Animated GIFs play; pause from their corner button | yes | yes (#39) | The corner button hides with Show Media Controls | -| See All Slides grid during the show | yes | yes: G, or All on the show bar and in presenter view; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | +| See All Slides grid during the show | yes | yes: G, or All on the show bar; click a thumbnail, Esc closes the grid | Hidden slides are labelled; the first 48 slides are shown | | Zoom into part of a slide during the show | yes | yes: Z, then click the slide (or the presenter slide); 2×, Esc or another click leaves it | The presenter keeps the whole slide and draws a frame on the zoomed part | | Pen and highlighter ink | yes | pen only (Cmd+P pen, Cmd+A arrow, E erases all ink) | No highlighter, per-stroke eraser, ink colours, or keep/discard annotations prompt | | Laser pointer | yes | yes in the show | Cmd/Ctrl+L and the on-screen button draw the show's laser colour and leave no ink. The presenter window does not move that dot | @@ -50,7 +50,7 @@ ready** (estimated), **10–16 h** to parity. | Placed on the presenter's display automatically, show on the projector | yes | **no**: opens wherever the OS puts it | Beta blocker, 3–5 h. Open PR #433 places the show on a second display when one is attached. This change does not | | Swap displays | yes | no | Still open on #433 | | Pen, laser, black screen, zoom from presenter view | yes | Black, White and Zoom buttons; the audience zoom is framed on the presenter slide | Pen and laser are driven from the audience window only; the presenter window does not move the laser dot | -| See all slides from presenter view | yes | yes: All slides opens the grid on the show | | +| See all slides from presenter view | yes | yes: All slides (or G) opens the grid in presenter view only, the audience keeps the slide; click a thumbnail to go there, Esc closes it | | | Rehearse with Coach (Speaker Coach) | yes (cloud) | no | AI dimension | ## Rehearse and record @@ -79,6 +79,7 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) | | 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% | | 2026-10-13 | minor | Click handling for interactive decks: triggers, per-slide state kept across visits, visible-shape hit testing with hand cursor, advance on click | | 2026-10-13 | minor | Video seek handoff no longer discards freshly decoded frames or end-of-stream (upstream #416); deterministic queue and four-codec seek regression tests |