Browse / github.com/storytold/deckcraft / patch
Choose the slide show display in Set Up Slide Show; keep Presenter View on top, notes below
Choose the slide show display in Set Up Slide Show; keep Presenter View on top, notes below With two displays, the slide show and Presenter View both opened on the editor's display: the full-screen show covered Presenter View, which also did not remember where it was put. Now: - **Slide Show ▸ Set Up Slide Show ▸ Multiple monitors ▸ Slide show monitor** chooses where the show goes, as in PowerPoint: *Automatic* (the editor's display) or a specific display with its size. The show starts there each time, without asking. If the saved display is not connected, the show falls back to the editor's display and the saved choice is kept. OK saves the choice; Cancel, Esc and the close button
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- kinower (imported from pull request #1225, open)
- Base
ee875abffa1a31cb760c843bb29e79391a90a766- Size
- +533 −31 in 15 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fb34f4aa5bd9ac620cf7c1d8
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md apps/deckcraft/src/main.rs crates/ui-egui/src/app.rs crates/ui-egui/src/dialogs/body.rs crates/ui-egui/src/displays.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/settings.rs crates/ui-egui/src/show.rs crates/ui-egui/src/show/presenter.rs crates/ui-egui/src/show/render.rs crates/ui-egui/src/show/tests.rs crates/ui-egui/src/show/ui.rs crates/ui-egui/src/state.rs crates/ui-egui/src/texture_limit_tests.rs docs/slideshow-parity.md
@@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (Set Up Slide Show monitor selector, #1225; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)@@ -137,6 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---|+| 2026-10-26 | Set Up Slide Show selects the machine-local show monitor; OK commits, Cancel discards, browsed mode disables it (#1225) | | 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once | | 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |@@ -294,6 +295,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | trivial | Set Up Slide Show selects the machine-local monitor (#1225); scores unchanged | | 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged | | 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |@@ -27,6 +27,7 @@ mod logging; mod pasteboard; use deckcraft_engine::Session;+use deckcraft_ui_egui::displays::{Display, Displays}; use deckcraft_ui_egui::{Services, SlideApp}; struct App(@@ -46,7 +47,27 @@ impl eframe::App for App { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(ctx, raw); }- fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {+ fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {+ // The slide show and Presenter View choose their displays from these.+ if let Some(w) = frame.winit_window() {+ let list: Vec<_> = w.available_monitors().collect();+ let current = w.current_monitor();+ self.0.displays = Displays {+ editor: list.iter().position(|m| Some(m) == current.as_ref()),+ list: list+ .iter()+ .map(|m| {+ let (at, size) = (m.position(), m.size());+ Display {+ x: at.x,+ y: at.y,+ w: i32::try_from(size.width).unwrap_or(i32::MAX),+ h: i32::try_from(size.height).unwrap_or(i32::MAX),+ }+ })+ .collect(),+ };+ } self.0.ui(ui); } fn on_exit(&mut self) {@@ -105,6 +105,7 @@ impl SlideApp { ime_settles_seen: 0, ime_interrupt_pending: false, ime_reset: HashMap::new(),+ displays: displays::Displays::default(), } } @@ -221,8 +221,33 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } }); let _ = combo.response.labelled_by(field.id);+ ui.add_space(8.0);+ ui.label(egui::RichText::new("Multiple monitors").font(theme::bold(13.0)));+ // Keep the machine-local choice pending until OK; Cancel, Esc and closing discard it.+ let initial = app.ui.show_display.map_or_else(String::new, |i| i.to_string());+ let mut display = d.get("showDisplay", &initial).parse::<usize>().ok();+ let enabled = d.get("type", &sh.show_type) != "browsed";+ ui.add_enabled_ui(enabled, |ui| {+ ui.horizontal(|ui| {+ let label = ui.label("Slide show monitor:");+ let display_label = |i: usize| app.displays.list.get(i).map(|screen| format!("Display {} ({} × {})", i + 1, screen.w, screen.h));+ egui::ComboBox::from_id_salt("setup-show-monitor")+ .selected_text(display.and_then(display_label).unwrap_or_else(|| "Automatic".into()))+ .show_ui(ui, |ui| {+ ui.selectable_value(&mut display, None, "Automatic");+ for (i, screen) in app.displays.list.iter().enumerate() {+ ui.selectable_value(&mut display, Some(i), format!("Display {} ({} × {})", i + 1, screen.w, screen.h));+ }+ })+ .response+ .labelled_by(label.id)+ .widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, "Slide show monitor"));+ });+ });+ d.fields.insert("showDisplay".into(), display.map_or_else(String::new, |i| i.to_string())); let (ok, cancel) = buttons(ui, "OK"); if ok {+ app.ui.show_display = display; let ty = d.get("type", "speaker"); let custom = d.fields.get("custom").cloned().unwrap_or_default(); run(app, "show.setup", json!({"type": ty, "loop": lp, "noNarration": nn, "noAnimation": na, "useTimings": ut, "custom": custom}));new file mode 100644@@ -0,0 +1,162 @@+//! Which display the slide show and Presenter View use. Set Up Slide Show chooses the show's display,+//! and Presenter View opens in a window that+//! stays on top of the show and comes back where it was last left. Swap Displays trades them.+//!+//! The desktop app reports the displays ([`crate::SlideApp::displays`]) in physical pixels, in the+//! order `egui::ViewportCommand::SetMonitor` counts them. Window rectangles are `[x, y, w, h]`: the+//! outer position and the inner size, in physical pixels.++/// A display's area on the desktop, in physical pixels.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct Display {+ pub x: i32,+ pub y: i32,+ pub w: i32,+ pub h: i32,+}++impl Display {+ /// How much of `r` lies on this display, in square pixels.+ fn overlap(&self, r: [i32; 4]) -> i64 {+ let w = (self.x + self.w).min(r[0] + r[2]) - self.x.max(r[0]);+ let h = (self.y + self.h).min(r[1] + r[3]) - self.y.max(r[1]);+ if w > 0 && h > 0 { i64::from(w) * i64::from(h) } else { 0 }+ }++ /// A window of `size` centred on this display, shrunk to leave a margin around it.+ fn centre(&self, size: [i32; 2]) -> [i32; 4] {+ let w = size[0].clamp(1, (self.w - 2 * MARGIN).max(1));+ let h = size[1].clamp(1, (self.h - 2 * MARGIN).max(1));+ [self.x + (self.w - w) / 2, self.y + (self.h - h) / 2, w, h]+ }+}++/// Room kept around a window placed on a display, in physical pixels (title bar, taskbar).+const MARGIN: i32 = 40;+/// A window's title bar height, and how much of its width must show to grab it (physical pixels).+const TITLE_BAR: i32 = 32;+const GRAB: i32 = 160;++/// The displays, and the one the editor window is on.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Displays {+ pub list: Vec<Display>,+ pub editor: Option<usize>,+}++impl Displays {+ /// The display most of `r` is on.+ fn under(&self, r: [i32; 4]) -> Option<usize> {+ let (i, area) = self.list.iter().enumerate().map(|(i, d)| (i, d.overlap(r))).max_by_key(|&(i, a)| (a, std::cmp::Reverse(i)))?;+ (area > 0).then_some(i)+ }++ /// A display other than `not`, the editor's first.+ fn other(&self, not: Option<usize>) -> Option<usize> {+ self.editor.filter(|&e| Some(e) != not && e < self.list.len()).or_else(|| (0..self.list.len()).find(|&i| Some(i) != not))+ }++ /// The saved display while it is still there, else the editor's.+ pub fn first_choice(&self, last: Option<usize>) -> usize {+ let valid = |i: &usize| *i < self.list.len();+ last.filter(valid).or(self.editor.filter(valid)).unwrap_or(0)+ }++ /// Can the window at `r` still be grabbed: is enough of its title bar on one display?+ fn reachable(&self, r: [i32; 4]) -> bool {+ let bar = [r[0], r[1], r[2], TITLE_BAR];+ self.list.iter().any(|d| d.overlap(bar) >= i64::from(GRAB.min(r[2])) * i64::from(TITLE_BAR))+ }++ /// Where Presenter View's window opens: where it was left (`saved`) while its title bar can+ /// still be grabbed, even over the show (it stays on top); else (a display gone, a resolution+ /// changed) centred on a display other than the show's when there is one, with the saved size+ /// or `size`. `None` leaves it to the system.+ pub fn presenter(&self, saved: Option<[i32; 4]>, show: Option<usize>, size: [i32; 2]) -> Option<[i32; 4]> {+ let saved = saved.filter(|r| r[2] > 0 && r[3] > 0);+ if self.list.is_empty() || saved.is_some_and(|r| self.reachable(r)) {+ return saved;+ }+ let d = self.other(show).unwrap_or(0);+ let size = saved.map_or(size, |r| [r[2], r[3]]);+ self.list.get(d).map(|d| d.centre(size))+ }++ /// Swap Displays: the show moves to the display Presenter View is on (the next one when they+ /// share one) and Presenter View, keeping its size, to the display the show leaves.+ pub fn swap(&self, show: usize, presenter: [i32; 4]) -> Option<(usize, [i32; 4])> {+ let n = self.list.len();+ if n < 2 || show >= n {+ return None;+ }+ let to = self.under(presenter).filter(|&d| d != show).unwrap_or((show + 1) % n);+ Some((to, self.list[show].centre([presenter[2], presenter[3]])))+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ /// A 1920×1080 laptop at the origin and a 1280×720 projector to its right.+ fn two() -> Displays {+ Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }, Display { x: 1920, y: 0, w: 1280, h: 720 }], editor: Some(0) }+ }++ #[test]+ fn the_saved_display_falls_back_when_disconnected() {+ let mut d = two();+ assert_eq!(d.first_choice(None), 0, "the editor's display");+ assert_eq!(d.first_choice(Some(1)), 1);+ d.editor = Some(1);+ assert_eq!(d.first_choice(None), 1);+ // A display that has gone: the editor's.+ assert_eq!(d.first_choice(Some(5)), 1);+ d.editor = Some(7);+ assert_eq!(d.first_choice(Some(5)), 0);+ assert_eq!(Displays::default().first_choice(Some(3)), 0);+ }++ #[test]+ fn presenter_view_comes_back_where_it_was_left() {+ let d = two();+ let left = [100, 120, 900, 600];+ assert_eq!(d.presenter(Some(left), Some(1), [1100, 700]), Some(left));+ // On the show's display too: it stays on top of the show.+ assert_eq!(d.presenter(Some([2000, 50, 900, 600]), Some(1), [1100, 700]), Some([2000, 50, 900, 600]));+ // Nothing saved, or saved on a display that is gone: centred on another display.+ assert_eq!(d.presenter(None, Some(1), [1100, 700]), Some([410, 190, 1100, 700]));+ assert_eq!(d.presenter(Some([-3000, 0, 800, 500]), Some(1), [1100, 700]), Some([560, 290, 800, 500]));+ // Left almost off screen after a resolution change, or with its title bar above the top:+ // it can't be grabbed, so it is centred again.+ assert_eq!(d.presenter(Some([3199, 100, 900, 600]), Some(1), [1100, 700]), Some([510, 240, 900, 600]));+ assert_eq!(d.presenter(Some([100, -200, 900, 600]), Some(1), [1100, 700]), Some([510, 240, 900, 600]));+ // Enough of its title bar shows: it stays where it is.+ assert_eq!(d.presenter(Some([1700, 100, 900, 600]), Some(1), [1100, 700]), Some([1700, 100, 900, 600]));+ // Larger than the display: shrunk to fit.+ assert_eq!(d.presenter(None, Some(0), [4000, 3000]), Some([1960, 40, 1200, 640]));+ }++ #[test]+ fn with_one_display_presenter_view_keeps_its_place_over_the_show() {+ let one = Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }], editor: Some(0) };+ assert_eq!(one.presenter(Some([100, 100, 800, 500]), None, [1100, 700]), Some([100, 100, 800, 500]));+ assert_eq!(one.presenter(Some([5000, 100, 800, 500]), None, [1100, 700]), Some([560, 290, 800, 500]));+ // No display list (tests, web): whatever was saved, else the system's choice.+ assert_eq!(Displays::default().presenter(Some([1, 2, 3, 4]), None, [1100, 700]), Some([1, 2, 3, 4]));+ assert_eq!(Displays::default().presenter(None, None, [1100, 700]), None);+ }++ #[test]+ fn swap_displays_trades_the_show_and_presenter_view() {+ let d = two();+ // Presenter View on the laptop, the show on the projector: they trade.+ assert_eq!(d.swap(1, [100, 100, 900, 600]), Some((0, [2110, 60, 900, 600])));+ // Both on the laptop: the show goes to the next display, Presenter View stays on the laptop.+ assert_eq!(d.swap(0, [100, 100, 900, 600]), Some((1, [510, 240, 900, 600])));+ let three = Displays { list: [0, 1920, 3840].map(|x| Display { x, y: 0, w: 1920, h: 1080 }).to_vec(), editor: Some(0) };+ assert_eq!(three.swap(2, [2000, 100, 900, 600]).map(|s| s.0), Some(1));+ assert_eq!(three.swap(2, [4000, 100, 900, 600]).map(|s| s.0), Some(0));+ assert_eq!(Displays::default().swap(0, [0, 0, 10, 10]), None);+ }+}@@ -11,6 +11,7 @@ pub mod canvas; pub mod capture; pub mod control; pub mod dialogs;+pub mod displays; pub mod doc_window; pub mod eyedropper; pub mod icons;@@ -318,6 +319,8 @@ pub struct SlideApp { next_file_id: u64, /// Picks and drops whose bytes have not arrived yet. pending_files: Vec<PendingFile>,+ /// The displays, kept current by the desktop app; empty where it cannot tell ([`displays`]).+ pub displays: displays::Displays, /// Owner/generation/cause bookkeeping for the canvas text IME producer. ime_tracker: egui::ime_context::ImeContextTracker, /// Canvas IME geometry for this pass: the caret while editing, or the selected shape.@@ -126,11 +126,13 @@ mod tests { #[test] fn saving_round_trips_and_leaves_no_temporary_file() { let d = dir("save");- let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], ..Default::default() };+ let ui = UiState { zoom: Some(1.5), recent: vec!["/a.pptx".into()], show_display: Some(1), ..Default::default() }; let prefs = Prefs { author: "Ann".into(), units: deckcraft_geom::Unit::Centimeters, ..Default::default() }; save(&d.join("nested"), &ui, &prefs); let (u, p) = load(&d.join("nested"));- assert_eq!(u.unwrap().zoom, Some(1.5));+ let u = u.unwrap();+ assert_eq!(u.zoom, Some(1.5));+ assert_eq!(u.show_display, Some(1)); let p = p.unwrap(); assert_eq!((p.author.as_str(), p.units), ("Ann", deckcraft_geom::Unit::Centimeters)); let names: Vec<String> =@@ -100,6 +100,13 @@ pub struct Show { /// visible frame. `run_logic` does not refresh `InputState::time`, so this lets a hidden show /// keep playing from wall time without a draw. hidden_anchor: Option<(f64, web_time::Instant)>,+ /// The display the show fills, when there are several ([`crate::displays`]).+ display: Option<usize>,+ /// Where to put Presenter View's window, and whether it has been moved there (it is sized on+ /// the next frame, once it has the new display's scale).+ presenter_place: Option<([i32; 4], bool)>,+ /// Where Presenter View's window is, in physical pixels ([`crate::displays`]).+ presenter_at: Option<[i32; 4]>, } #[derive(Clone, Debug, PartialEq)]@@ -132,7 +139,7 @@ struct BackKey { /// A slide's backdrop (background, master and layout graphics), kept while the slide is shown so /// animation frames render only the slide's own shapes over it. The backdrop can cost far more-/// than the shapes: a full-slide picture with a glow in the layout made animations crawl at 2–3+/// than the shapes: a full-slide picture with a glow in the layout made animations crawl at 2–3 /// frames a second. `rx` brings it from another thread when it is rendered ahead of time. struct Backdrop { /// The presentation it was rendered from, held so its address (in `key`) can't be reused by@@ -149,7 +156,7 @@ struct Transition { from: Option<usize>, morph: Option<TextureHandle>, /// Backgrounds and contents of the old and new slides, rendered once when the transition- /// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new+ /// moves contents over backgrounds (Pan, Ferris Wheel, Reveal…): old back, old content, new /// back, new content. parts: Option<[Option<TextureHandle>; 4]>, start: f64,@@ -241,6 +248,9 @@ impl Show { media_clock: None, media_cues: vec![], hidden_anchor: None,+ display: None,+ presenter_place: None,+ presenter_at: None, } } @@ -3,6 +3,8 @@ use super::render::{fit, next_rect}; use super::zoom::{Zoom, grid_indices, paint_grid, see_all_hit, slide_aspect, toggle_blank, zoom_frame}; use super::*; +pub(super) const PRESENTER_SIZE: [f32; 2] = [1100.0, 700.0];+ pub(super) const NOTES_DEFAULT: f32 = 18.0; pub(super) const NOTES_MIN: f32 = 12.0;@@ -75,8 +77,48 @@ pub(super) fn wall_clock() -> String { } } -/// 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) {+/// Moves Presenter View's window to `show.presenter_place` (one frame to move it, the next to size+/// it at the new display's scale), else notes where it is (`show.presenter_at`). Runs in its viewport.+fn place_presenter(ctx: &egui::Context, show: &mut Show) {+ let ppp = ctx.pixels_per_point();+ let points = |px: i32| px as f32 / ppp;+ match show.presenter_place {+ Some((r, false)) => {+ ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos2(points(r[0]), points(r[1]))));+ show.presenter_place = Some((r, true));+ ctx.request_repaint();+ }+ Some((r, true)) => {+ ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(vec2(points(r[2]), points(r[3]))));+ show.presenter_place = None;+ show.presenter_at = Some(r);+ }+ None => {+ let at = ctx.input(|i| {+ let v = i.viewport();+ // A minimized window sits far off screen.+ if v.minimized == Some(true) || v.fullscreen == Some(true) {+ return None;+ }+ let (outer, inner) = (v.outer_rect?, v.inner_rect?);+ let px = |v: f32| (v * ppp).round() as i32;+ Some([px(outer.min.x), px(outer.min.y), px(inner.width()), px(inner.height())])+ });+ if at.is_some() {+ show.presenter_at = at;+ }+ }+ }+}++pub(super) fn presenter_window(+ ctx: &egui::Context,+ show: &mut Show,+ doc: &Presentation,+ displays: &crate::displays::Displays,+ 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());@@ -108,11 +150,18 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese }; ctx.show_viewport_immediate( egui::ViewportId::from_hash_of("deckcraft_presenter"),- egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]),+ // On top, so the full-screen show never hides it when they share a display.+ // On Wayland, set_outer_position and set_window_level are unsupported: the+ // compositor chooses Presenter View's display and stacking order instead.+ egui::ViewportBuilder::default()+ .with_title("Presenter View — DeckCraft")+ .with_inner_size(PRESENTER_SIZE)+ .with_window_level(egui::WindowLevel::AlwaysOnTop), |ui, _| { if !keyboard(ui.ctx(), show, doc, now, true) { *keep = false; }+ place_presenter(ui.ctx(), show); ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); let elapsed = show.timer.elapsed(now); let paused = show.timer.paused();@@ -158,8 +207,20 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese if button(ui, show.zoom_label()).clicked() { show.zoom_key(); }+ // Swap Displays (PowerPoint's Display Settings), with more than one display.+ if show_fills_screen(show.reading, &doc.show.show_type)+ && let Some(d) = show.display+ && button(ui, "Swap Displays").clicked()+ && let Some((to, r)) = show.presenter_at.and_then(|at| displays.swap(d, at))+ {+ ui.ctx().send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::SetMonitor(to));+ ui.ctx().request_repaint_of(egui::ViewportId::ROOT);+ show.display = Some(to);+ show.presenter_place = Some((r, false));+ } }); ui.add_space(8.0);+ // The current and next slides side by side on top, the notes across the bottom. let avail = ui.available_rect_before_wrap(); if show.presenter_grid { // The grid fills this window; the audience keeps seeing the current slide.@@ -178,9 +239,10 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese } 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);+ let top = avail.min.y + avail.height() * 0.62;+ let left = Rect::from_min_max(avail.min, pos2(avail.min.x + avail.width() * 0.62, top));+ let right = Rect::from_min_max(pos2(left.max.x + 16.0, avail.min.y), pos2(avail.max.x, top));+ let cr = fit(left, doc); let cr = Rect::from_min_size(left.min, cr.size()); if let Some(t) = &cur_tex { ui.painter().image(t.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);@@ -215,21 +277,24 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese 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));+ let notes_head = Rect::from_min_size(pos2(avail.min.x, cr.max.y.max(nr.max.y) + 12.0), vec2(avail.width(), 32.0)); let mut head = ui.new_child(egui::UiBuilder::new().max_rect(notes_head)); head.horizontal(|ui| { ui.label(egui::RichText::new("Notes").size(13.0).color(Color32::from_gray(170)));- if ui.button("A-").clicked() {+ if button(ui, "A-").clicked() { show.nudge_notes(-NOTES_STEP); } ui.label(egui::RichText::new(format!("{}", show.notes_px as i32)).size(13.0).color(Color32::from_gray(200)));- if ui.button("A+").clicked() {+ if button(ui, "A+").clicked() { show.nudge_notes(NOTES_STEP); } });- let notes_rect = Rect::from_min_max(pos2(right.min.x, notes_head.max.y + 4.0), right.max);- let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));+ // Black on white, like a printed page, for reading at a glance.+ let notes_rect = Rect::from_min_max(pos2(avail.min.x, notes_head.max.y + 4.0), avail.max);+ ui.painter().rect_filled(notes_rect, CornerRadius::same(4), Color32::WHITE);+ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect.shrink(12.0))); egui::ScrollArea::vertical().show(&mut child, |ui| {+ ui.set_width(ui.available_width()); presenter_notes(ui, notes, show.notes_px); }); });@@ -249,7 +314,7 @@ pub(super) fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Prese /// The current slide's notes in the presenter's notes pane, `px` for 12 pt text. pub(super) fn presenter_notes(ui: &mut Ui, notes: &deckcraft_model::text::TextBody, px: f32) -> egui::Response {- let color = Color32::from_gray(230);+ let color = Color32::BLACK; if notes.text().is_empty() { ui.label(egui::RichText::new("No notes.").size(px).color(color)) } else {@@ -151,10 +151,10 @@ pub(super) fn fit(avail: Rect, doc: &Presentation) -> Rect { /// A transition layer as an egui mesh: [`deckcraft_anim::layer_mesh`]'s triangles (perspective /// cards subdivided so their pictures stay straight) with per-vertex opacity. /// Where presenter view's "Next" preview goes: the column's width below its label, kept inside-/// the top 60% of the column. A portrait slide used to get the full width and a height many-/// times the window (a white slab over the notes and past the window edge).+/// the column. A portrait slide used to get the full width and a height many times the window+/// (a white slab over the notes and past the window edge). pub(super) fn next_rect(right: Rect, doc: &Presentation) -> Rect {- let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.min.y + 22.0 + (right.height() - 22.0).max(0.0) * 0.6));+ let area = Rect::from_min_max(right.min + vec2(0.0, 22.0), pos2(right.max.x, right.max.y.max(right.min.y + 22.0))); Rect::from_min_size(area.min, fit(area, doc).size()) } @@ -64,7 +64,7 @@ fn presenter_all_slides_does_not_cover_the_audience_screen() { 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<Presentation>, Show, bool)| { let now = ui.input(|i| i.time);- presenter_window(ui.ctx(), show, doc, now, keep);+ presenter_window(ui.ctx(), show, doc, &crate::displays::Displays::default(), now, keep); }, (doc, show, true), );@@ -288,7 +288,7 @@ fn presenter_navigation_uses_its_own_keyboard_events() { 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<Presentation>, Show, bool)| { let now = ui.input(|i| i.time);- presenter_window(ui.ctx(), show, doc, now, keep);+ presenter_window(ui.ctx(), show, doc, &crate::displays::Displays::default(), now, keep); }, (doc, show, true), );@@ -993,3 +993,173 @@ 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"); }++/// Set Up Slide Show selects the display; starting and restarting a show never asks again.+#[test]+fn the_show_uses_the_saved_display_without_asking() {+ use crate::displays::{Display, Displays};+ use egui_kittest::kittest::Queryable;++ let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());+ app.ui.presenter_view = true;+ app.displays = Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }, Display { x: 1920, y: 0, w: 1280, h: 720 }], editor: Some(0) };+ let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(3);+ // The setting is used on each start, with automatic and disconnected-display fallbacks.+ for (saved, expected) in [(Some(1), 1), (Some(1), 1), (None, 0), (Some(5), 0)] {+ harness.state_mut().ui.show_display = saved;+ harness.state_mut().start_show(0, false);+ harness.run_steps(2);+ let show = harness.state().show.as_ref().unwrap();+ assert!(show.started);+ assert_eq!(show.display, Some(expected));+ assert_eq!(harness.state().ui.show_display, saved);+ assert!(harness.query_by_label_contains("Which display").is_none());+ harness.key_press(egui::Key::Escape);+ harness.run_steps(2);+ assert!(harness.state().show.is_none());+ }+ // Windowed reading mode must not be moved to the saved display.+ harness.state_mut().ui.show_display = Some(1);+ harness.state_mut().start_show(0, true);+ harness.run_steps(2);+ assert_eq!(harness.state().show.as_ref().unwrap().display, None);+ harness.key_press(egui::Key::Escape);+ harness.run_steps(2);+ harness.state_mut().displays.list.truncate(1);+ harness.state_mut().start_show(0, false);+ harness.run_steps(2);+ let show = harness.state().show.as_ref().unwrap();+ assert!(show.started);+ assert_eq!(show.display, None);+}++#[test]+fn browsed_show_neither_selects_nor_swaps_displays() {+ use crate::displays::{Display, Displays};+ use egui_kittest::kittest::Queryable;++ let mut session = deckcraft_engine::Session::with_new();+ session.execute("show.setup", &json!({"type": "browsed"})).unwrap();+ let mut app = SlideApp::new(session, crate::Services::default());+ app.ui.presenter_view = true;+ app.ui.show_display = Some(1);+ app.displays = Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }, Display { x: 1920, y: 0, w: 1280, h: 720 }], editor: Some(0) };+ let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(3);+ harness.state_mut().start_show(0, false);+ harness.run_steps(2);+ let show = harness.state().show.as_ref().unwrap();+ assert!(show.started);+ assert!(show.presenter);+ assert_eq!(show.display, None, "a browsed show ignores the saved display");+ assert_eq!(harness.state().ui.show_display, Some(1));++ // Even a stale display assignment must not expose a path to SetMonitor.+ let app = harness.state_mut();+ let mut show = app.show.take().unwrap();+ show.display = Some(1);+ show.presenter_at = Some([0, 0, 1000, 700]);+ let doc = app.session.active().unwrap().doc.clone();+ let displays = app.displays.clone();+ let mut presenter = egui_kittest::Harness::builder().with_size(vec2(1100.0, 700.0)).build_ui_state(+ move |ui, (show, keep): &mut (Show, bool)| {+ presenter_window(ui.ctx(), show, &doc, &displays, ui.input(|i| i.time), keep);+ },+ (show, true),+ );+ presenter.run_steps(2);+ assert!(presenter.query_by_label("Swap Displays").is_none());+}++#[test]+fn setup_show_display_selection_can_be_cancelled_or_saved() {+ use crate::displays::{Display, Displays};+ use egui_kittest::kittest::NodeT;+ use egui_kittest::kittest::Queryable;++ let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());+ app.displays = Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }, Display { x: 1920, y: 0, w: 1280, h: 720 }], editor: Some(0) };+ let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 800.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ h.run_steps(3);+ for close in ["Cancel", "Escape", "OK"] {+ h.state_mut().run("app.dialog", json!({"id": "setupShow"})).unwrap();+ h.run_steps(2);+ h.get_by_label("Slide show monitor").click_accesskit();+ h.run_steps(2);+ h.get_by_label("Display 2 (1280 × 720)").click_accesskit();+ h.run_steps(2);+ assert_eq!(h.state().ui.show_display, None, "selection stays pending until OK");+ if close == "Escape" {+ h.key_press(egui::Key::Escape);+ } else {+ h.get_by_label(close).click_accesskit();+ }+ h.run_steps(2);+ assert!(h.state().dialog.is_none());+ assert_eq!(h.state().ui.show_display, if close == "OK" { Some(1) } else { None });+ }+ let saved = serde_json::to_value(&h.state().ui).unwrap();+ assert_eq!(saved["showDisplay"], json!(1));+ let restored: crate::UiState = serde_json::from_value(saved).unwrap();+ assert_eq!(restored.show_display, Some(1));+ h.state_mut().start_show(0, false);+ h.run_steps(2);+ assert!(h.state().show.as_ref().unwrap().started);+ assert_eq!(h.state().show.as_ref().unwrap().display, Some(1));+ assert!(h.query_by_label_contains("Which display").is_none());+ h.key_press(egui::Key::Escape);+ h.run_steps(2);+ h.state_mut().run("app.dialog", json!({"id": "setupShow"})).unwrap();+ h.run_steps(2);+ h.get_by_label("Browsed by an individual (window)").click_accesskit();+ h.run_steps(2);+ assert!(h.get_by_label("Slide show monitor").accesskit_node().is_disabled());+ h.get_by_label("OK").click_accesskit();+ h.run_steps(2);+ assert_eq!(h.state().ui.show_display, Some(1));+ h.state_mut().start_show(0, false);+ h.run_steps(2);+ assert_eq!(h.state().show.as_ref().unwrap().display, None);+}++#[test]+fn saved_display_is_used_without_presenter_view() {+ use crate::displays::{Display, Displays};+ let mut app = SlideApp::new(deckcraft_engine::Session::with_new(), crate::Services::default());+ app.ui.presenter_view = false;+ app.ui.show_display = Some(1);+ app.displays = Displays { list: vec![Display { x: 0, y: 0, w: 1920, h: 1080 }, Display { x: 1920, y: 0, w: 1280, h: 720 }], editor: Some(0) };+ let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(+ |ui, app: &mut SlideApp| {+ app.logic(ui.ctx());+ app.ui(ui);+ },+ app,+ );+ harness.run_steps(3);+ harness.state_mut().start_show(0, false);+ harness.run_steps(1);+ let show = harness.state().show.as_ref().unwrap();+ assert_eq!(show.display, Some(1));+ assert!(!show.presenter);+ assert!(show.presenter_place.is_none());+}@@ -2,7 +2,7 @@ use super::keys::keyboard; use super::media::{ Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media, };-use super::presenter::{PresenterTimer, presenter_window};+use super::presenter::{PRESENTER_SIZE, PresenterTimer, presenter_window}; use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch}; use super::zoom::{ Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,@@ -39,6 +39,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } show.observe_visible_frame(now, web_time::Instant::now()); if !show.started {+ // Use the display selected in Set Up Slide Show, falling back when it was disconnected.+ if show_fills_screen(show.reading, &doc.show.show_type) && app.displays.list.len() > 1 {+ show.display = Some(app.displays.first_choice(app.ui.show_display));+ } show.started = true; show.slide_start = now; show.step_start = now;@@ -49,7 +53,15 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { show.timer = PresenterTimer::start(now); show.last_move = now; if show_fills_screen(show.reading, &doc.show.show_type) {- ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));+ ctx.send_viewport_cmd(match show.display {+ Some(i) => egui::ViewportCommand::SetMonitor(i),+ None => egui::ViewportCommand::Fullscreen(true),+ });+ if show.presenter {+ let px = |v: f32| (v * ctx.pixels_per_point()).round() as i32;+ let size = [px(PRESENTER_SIZE[0]), px(PRESENTER_SIZE[1])];+ show.presenter_place = app.displays.presenter(app.ui.presenter_window, show.display, size).map(|r| (r, false));+ } } show.after_enter(&doc, now); }@@ -587,7 +599,17 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { } // Presenter view in a second window. if keep && show.presenter && !show.reading {- presenter_window(&ctx, &mut show, &doc, now, &mut keep);+ let previous_display = show.display;+ presenter_window(&ctx, &mut show, &doc, &app.displays, now, &mut keep);+ // Remembered for the next show, as PowerPoint does.+ if show.presenter_at.is_some() {+ app.ui.presenter_window = show.presenter_at;+ }+ // Only an explicit Swap Displays changes the preference; automatic/fallback placement+ // must not replace the user's saved choice.+ if show.display != previous_display {+ app.ui.show_display = show.display;+ } } if keep { app.show = Some(show);@@ -76,6 +76,10 @@ pub struct UiState { pub sorter_zoom: f32, pub recent: Vec<String>, pub presenter_view: bool,+ /// Where Presenter View's window was left: outer position and inner size in physical pixels.+ pub presenter_window: Option<[i32; 4]>,+ /// The slide show display selected in Set Up Slide Show (also updated by Swap Displays).+ pub show_display: Option<usize>, pub ui_scale: f32, /// Format Shape pane: tab (`fill`, `effects`, `size`, `picture`, `text`). pub format_tab: String,@@ -115,6 +119,8 @@ impl Default for UiState { sorter_zoom: 1.0, recent: vec![], presenter_view: true,+ presenter_window: None,+ show_display: None, ui_scale: 1.0, format_tab: "fill".into(), smartart_row: 0,@@ -152,6 +158,8 @@ impl UiState { self.format_tab = d.format_tab; } self.pane = self.pane.filter(short);+ self.presenter_window = self.presenter_window.filter(|r| (200..=20_000).contains(&r[2]) && (150..=20_000).contains(&r[3]));+ self.show_display = self.show_display.filter(|&d| d < 16); self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0')); self.recent.dedup(); self.recent.truncate(RECENT_MAX);@@ -64,7 +64,11 @@ fn presenter_view_keeps_a_portrait_next_slide_in_its_column() { h.state_mut().run("show.start", json!({"from": 0})).unwrap(); h.run_steps(4); let img = h.render().unwrap();- // Right of the Next column's narrow preview, below where a landscape one would end.- let px = img.get_pixel(1000, 600).0;+ // Right of the Next column's narrow preview, below where a landscape one would end (and above+ // the notes across the bottom).+ let px = img.get_pixel(1000, 470).0; assert!(px[0] < 128 && px[1] < 128 && px[2] < 128, "the Next preview spills over the column: {px:?}");+ // The notes run across the bottom, black on white.+ let px = img.get_pixel(600, 650).0;+ assert!(px[0] > 240 && px[1] > 240 && px[2] > 240, "the notes area is white: {px:?}"); }@@ -1,6 +1,6 @@ # Slide show and presenter view: parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (Ctrl+wheel zooms the show around the pointer, #1286; previous: 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-26 · **Change:** minor (slide show monitor selection moves to Set Up Slide Show, #1225; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: 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@@ -10,6 +10,12 @@ ready** (estimated), **10–16 h** to parity. ## Running the show +Set Up Slide Show includes **Multiple monitors → Slide show monitor**: Automatic or a+connected display with its dimensions. OK commits the choice to the machine's `ui.json`+(`showDisplay`); Cancel, Escape and the close button discard it. It is disabled for a+browsed show. Starting a show uses the saved choice without prompting, with the existing+automatic, disconnected-display and windowed fallbacks. Swap Displays updates the same setting.+ | Feature | PowerPoint | DeckCraft | Notes | |---|---|---|---| | Play from Start / from Current Slide (F5, Shift+F5, Cmd+Enter) | yes | yes | |@@ -47,8 +53,8 @@ ready** (estimated), **10–16 h** to parity. | Slide counter, previous/next/end buttons | yes | yes | | | Keyboard navigation while Presenter View has focus | yes | yes | Shares the audience window's key handling; handled keys are consumed before buttons, and the presenter stays available at the end screen | | Notes font size, scrolling | yes | yes: A- / A+ (and `[` / `]`), 12–48 pt in steps of 4, default 18; the notes scroll | |-| 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 |+| Placed on the presenter's display automatically, show on the projector | yes | yes: the saved show display is used for full-screen shows; Presenter View restores its saved placement or uses another display | On Wayland, the compositor controls window placement and stacking |+| Swap displays | yes | yes: moves the audience show and Presenter View and saves the show display | On Wayland, the compositor controls Presenter View placement | | 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 (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 |@@ -79,6 +85,7 @@ Reading View in a window with navigation: present (checklist row done). | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Move the machine-local monitor selector from Preferences to Set Up Slide Show (#1225); scores unchanged | | 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 |