collective

Browse / github.com/storytold/deckcraft / patch

Better dual screen and Wayland handling

Better dual screen and Wayland handling While working on https://github.com/storytold/deckcraft/pull/1161 I noticed other issues affecting the presentation performance. The **Presenter View** opened whenever its setting was on, which is the default, however many screens were connected. DeckCraft never chose a screen for either window, so both usually landed on the same screen and one covered the other. Rendering with vsync, a covered window waits up to about a second for the compositor to let it draw. Presenter View is drawn within the main window's update, so the whole show stalled: an animation drew one or two frames and looked l

Status
open
Score
0.00
Adopters
0 other operators
Author
kbarni (imported from pull request #1184, open)
Base
5d9c3818a5e145e5e302de90ed9fb48387d50fcd
Size
+276 −180 in 6 files
Review
pending
Shared
2026-10-28
Id
a4c199ffbfa0bafb7ce8f6bc

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md apps/deckcraft/Cargo.toml apps/deckcraft/src/display.rs apps/deckcraft/src/main.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/show.rs

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -142,6 +142,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-24 | A picture's own fill shows behind its transparent pixels, and an embedded object (an old equation, `p:oleObj`) takes the fill and outline of its fallback picture, as PowerPoint for Mac draws them (synthetic probe: a half-transparent PNG on orange and on yellow with an outline); on a private 166-deck corpus 51 slides of equations on coloured boxes move closer to PowerPoint's PDF, none further |+| 2026-10-22 | Slide Show with Presenter View, as in PowerPoint: with two screens the slides go full screen on one and Presenter View on the presenter's; with one screen only the slides show. Both windows on one screen covered one, and a covered window stalled the show at about a frame a second. In a Wayland session DeckCraft now starts on Wayland (`DECKCRAFT_X11` asks for X11), where windows go to the screen asked for | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml--- a/apps/deckcraft/Cargo.toml+++ b/apps/deckcraft/Cargo.toml@@ -29,6 +29,8 @@ rfd = { workspace = true } arboard = { workspace = true, features = ["image-data"] } image = { workspace = true } cpal = "0.16"+# The screens a slide show and Presenter View go to (the same winit eframe runs on).+winit = { version = "0.30", default-features = false }  [target.'cfg(target_os = "macos")'.dependencies] # Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as adiff --git a/apps/deckcraft/src/display.rs b/apps/deckcraft/src/display.rs--- a/apps/deckcraft/src/display.rs+++ b/apps/deckcraft/src/display.rs@@ -1,54 +1,46 @@ //! Which windowing system to start on, on Linux and the BSDs. //!-//! DeckCraft prefers X11 when `DISPLAY` is set (see `main`). A stale `DISPLAY` (left over from-//! an ended `ssh -X` session or a desktop with Xwayland turned off) used to make start-up fail-//! with winit's "failed to open display" even though a Wayland compositor was running. When a-//! local `DISPLAY` names an X server whose socket isn't there and Wayland is available, start on-//! Wayland instead. With no display at all, say so plainly.--use std::path::Path;+//! In a Wayland session DeckCraft starts on Wayland, even when Xwayland also sets `DISPLAY`.+//! Under Xwayland the compositor can ignore where DeckCraft puts its windows: a slide show's+//! slides stayed on the presenter's screen, over Presenter View, and the covered window stalled+//! the show. A stale `DISPLAY` (an ended `ssh -X` session, Xwayland turned off) no longer matters+//! either. `DECKCRAFT_X11` asks for X11 anyway; without Wayland, X11 is used. With no display at+//! all, say so plainly.  /// The windowing system to ask winit for. #[derive(Debug, PartialEq, Eq)] pub enum Choice {     X11,-    /// Let winit decide (Wayland when `WAYLAND_DISPLAY` is set).+    /// Let winit decide (Wayland when `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set).     Default,-    /// Neither `DISPLAY` nor `WAYLAND_DISPLAY`: there is nothing to open a window on.+    /// Neither `DISPLAY` nor Wayland: there is nothing to open a window on.     None, } -/// The X server number of a local `DISPLAY` (`:1`, `:1.0`, `unix:1`); `None` for a remote one-/// (`host:10.0`, as `ssh -X` sets) or one we can't read, which are left to X11 itself.-fn local_display_number(display: &str) -> Option<u32> {-    let rest = display.strip_prefix(':').or_else(|| display.strip_prefix("unix:"))?;-    rest.split('.').next()?.parse().ok()-}--/// Choose from the environment. `socket_exists` checks an X socket path (the filesystem in-/// [`choose_from_env`], a stub in tests).-pub fn choose(display: Option<&str>, wayland: Option<&str>, force_wayland: bool, socket_exists: impl Fn(&Path) -> bool) -> Choice {-    let display = display.filter(|d| !d.is_empty());+/// Choose from the environment: `DISPLAY`, the Wayland connection ([`wayland`]) and whether X11+/// is asked for.+pub fn choose(display: Option<&str>, wayland: Option<&str>, force_x11: bool) -> Choice {+    let display = display.is_some_and(|d| !d.is_empty());     let wayland = wayland.is_some_and(|w| !w.is_empty());-    match display {-        None if wayland => Choice::Default,-        None => Choice::None,-        Some(_) if force_wayland && wayland => Choice::Default,-        Some(d) => match local_display_number(d) {-            Some(n) if wayland && !socket_exists(&Path::new("/tmp/.X11-unix").join(format!("X{n}"))) => {-                log::warn!("DISPLAY={d} has no X server; starting on Wayland");-                Choice::Default-            }-            _ => Choice::X11,-        },+    match (display, wayland) {+        (true, true) if force_x11 => Choice::X11,+        (_, true) => Choice::Default,+        (true, false) => Choice::X11,+        (false, false) => Choice::None,     } } +/// The Wayland connection winit would use: `WAYLAND_DISPLAY`, or else `WAYLAND_SOCKET` (a+/// connection handed over by whoever started DeckCraft).+fn wayland(display: Option<String>, socket: Option<String>) -> Option<String> {+    display.filter(|d| !d.is_empty()).or(socket)+}+ /// [`choose`] for this process. pub fn choose_from_env() -> Choice {     let display = std::env::var("DISPLAY").ok();-    let wayland = std::env::var("WAYLAND_DISPLAY").ok();-    choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_WAYLAND").is_some(), |p| p.exists())+    let wayland = wayland(std::env::var("WAYLAND_DISPLAY").ok(), std::env::var("WAYLAND_SOCKET").ok());+    choose(display.as_deref(), wayland.as_deref(), std::env::var_os("DECKCRAFT_X11").is_some()) }  #[cfg(test)]@@ -56,30 +48,31 @@ mod tests {     use super::*;      #[test]-    fn a_stale_local_display_falls_back_to_wayland() {-        let none = |_: &Path| false;-        let all = |_: &Path| true;-        assert_eq!(choose(Some(":1"), Some("wayland-0"), false, none), Choice::Default);-        assert_eq!(choose(Some(":1.0"), Some("wayland-0"), false, none), Choice::Default);-        assert_eq!(choose(Some("unix:0"), Some("wayland-0"), false, none), Choice::Default);-        // A live X server, no Wayland, or a remote DISPLAY: X11 as before.-        assert_eq!(choose(Some(":1"), Some("wayland-0"), false, all), Choice::X11);-        assert_eq!(choose(Some(":1"), None, false, none), Choice::X11);-        assert_eq!(choose(Some("host:10.0"), Some("wayland-0"), false, none), Choice::X11);-        // The socket checked is the display's own.-        assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X3")), Choice::X11);-        assert_eq!(choose(Some(":3"), Some("w"), false, |p: &Path| p.ends_with("X0")), Choice::Default);+    fn a_wayland_session_starts_on_wayland() {+        // Xwayland's DISPLAY, live or stale, doesn't take it over.+        assert_eq!(choose(Some(":0"), Some("wayland-0"), false), Choice::Default);+        assert_eq!(choose(Some(":1.0"), Some("wayland-1"), false), Choice::Default);+        assert_eq!(choose(None, Some("wayland-0"), false), Choice::Default);+        assert_eq!(choose(Some(""), Some("wayland-0"), false), Choice::Default);+    }++    #[test]+    fn a_wayland_socket_is_a_wayland_session() {+        let socket = wayland(None, Some("5".into()));+        assert_eq!(choose(Some(":0"), socket.as_deref(), false), Choice::Default);+        assert_eq!(wayland(Some(String::new()), Some("5".into())).as_deref(), Some("5"));+        assert_eq!(wayland(Some("wayland-0".into()), Some("5".into())).as_deref(), Some("wayland-0"));+        assert_eq!(wayland(None, None), None);     }      #[test]-    fn wayland_only_forced_and_no_display_at_all() {-        let all = |_: &Path| true;-        assert_eq!(choose(None, Some("wayland-0"), false, all), Choice::Default);-        assert_eq!(choose(Some(""), Some("wayland-0"), false, all), Choice::Default);-        assert_eq!(choose(Some(":0"), Some("wayland-0"), true, all), Choice::Default);-        // Forcing Wayland without a compositor keeps X11 rather than failing.-        assert_eq!(choose(Some(":0"), None, true, all), Choice::X11);-        assert_eq!(choose(None, None, false, all), Choice::None);-        assert_eq!(choose(Some(""), Some(""), false, all), Choice::None);+    fn x11_when_asked_or_alone_and_nothing_without_a_display() {+        assert_eq!(choose(Some(":0"), Some("wayland-0"), true), Choice::X11);+        assert_eq!(choose(Some(":0"), None, false), Choice::X11);+        assert_eq!(choose(Some("host:10.0"), Some(""), false), Choice::X11);+        // Asking for X11 without an X server keeps Wayland rather than failing.+        assert_eq!(choose(None, Some("wayland-0"), true), Choice::Default);+        assert_eq!(choose(None, None, false), Choice::None);+        assert_eq!(choose(Some(""), Some(""), true), Choice::None);     } }diff --git a/apps/deckcraft/src/main.rs b/apps/deckcraft/src/main.rs--- a/apps/deckcraft/src/main.rs+++ b/apps/deckcraft/src/main.rs@@ -135,6 +135,21 @@ fn services() -> Services {     } } +/// The screens `window` can show on, in winit's order (the one `SetMonitor` takes). A screen+/// winit lists twice (Wayland does) counts once, by its first number.+fn screens(window: &winit::window::Window) -> Option<deckcraft_ui_egui::Screens> {+    let all: Vec<_> = window.available_monitors().collect();+    let id = |m: &winit::monitor::MonitorHandle| (m.name(), m.position(), m.size());+    // Each listed screen's number, mapped to the first listing of the same screen.+    let first = |i: usize| all.get(i).and_then(|m| all.iter().position(|x| id(x) == id(m)));+    let list: Vec<usize> = (0..all.len()).filter(|i| first(*i) == Some(*i)).collect();+    if list.is_empty() {+        return None;+    }+    let find = |m: Option<winit::monitor::MonitorHandle>| m.and_then(|m| all.iter().position(|x| *x == m)).and_then(first);+    Some(deckcraft_ui_egui::Screens { list, current: find(window.current_monitor()), primary: find(window.primary_monitor()) })+}+ const APP_ID: &str = "ai.storyteller.deckcraft";  const USAGE: &str = "\@@ -259,7 +274,11 @@ fn main() -> eframe::Result {         options,         Box::new(move |cc| {             deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);-            let mut app = SlideApp::new(Session::new(), services());+            let mut services = services();+            if let Some(window) = cc.winit_window().cloned() {+                services.screens = Some(Box::new(move || screens(&window)));+            }+            let mut app = SlideApp::new(Session::new(), services);             load_prefs(&mut app);             // AutoRecover: reopen what a previous run left unsaved, then keep it current.             if std::env::var_os("DECKCRAFT_NO_RECOVERY").is_none() {diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -261,6 +261,36 @@ pub struct Services {     pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>,     /// Audio output for media playback (cpal on desktop); without one media plays silently.     pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>,+    /// The screens the computer shows to, read when a slide show starts. Without it (the web,+    /// tests) the show stays where the window is.+    pub screens: Option<Box<dyn FnMut() -> Option<Screens>>>,+}++/// The screens connected, numbered as the windowing system lists them (the numbers a window is+/// sent to with `ViewportCommand::SetMonitor`). Wayland can list a screen more than once: `list`+/// holds one number per screen.+#[derive(Clone, Debug, Default, PartialEq, Eq)]+pub struct Screens {+    pub list: Vec<usize>,+    /// The screen DeckCraft's window is on, when known.+    pub current: Option<usize>,+    /// The system's main screen, when known (Wayland doesn't say).+    pub primary: Option<usize>,+}++impl Screens {+    /// Where a slide show with Presenter View goes, as in PowerPoint: the slides full screen on+    /// another screen and Presenter View on the presenter's (the one DeckCraft is on). `None`+    /// with one screen: there, PowerPoint shows only the slides. Returns (slides, presenter).+    pub fn for_presenter_view(&self) -> Option<(usize, usize)> {+        if self.list.len() < 2 {+            return None;+        }+        let listed = |i: &usize| self.list.contains(i);+        let presenter = self.current.filter(listed).or(self.primary.filter(listed)).or_else(|| self.list.first().copied())?;+        let slides = self.list.iter().copied().find(|i| *i != presenter)?;+        Some((slides, presenter))+    } }  #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]@@ -1445,9 +1475,20 @@ impl SlideApp {     pub fn start_show(&mut self, from: usize, reading: bool) {         self.anim_preview = None;         self.media.stop_owned(media::Owner::Editor);+        let wanted = self.ui.presenter_view && !reading;+        // Presenter View needs a screen of its own: on one screen, PowerPoint shows only the+        // slides (two windows on one screen also hid one, and a hidden window stalled the show).+        let (presenter, screens) = match wanted.then(|| self.services.screens.as_mut().and_then(|f| f())).flatten() {+            Some(s) => match s.for_presenter_view().inspect(|p| log::info!("slide show on screen {} and Presenter View on {} of {s:?}", p.0, p.1)) {+                Some(placed) => (true, Some(placed)),+                None => (false, None),+            },+            None => (wanted, None),+        };         if let Some(d) = self.session.active() {             self.show_instance = self.show_instance.wrapping_add(1).max(1);-            let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading);+            let mut show = show::Show::new(&d.doc, from, reading, presenter);+            show.screens = screens;             show.instance = self.show_instance;             self.show = Some(show);         }diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs--- a/crates/ui-egui/src/show.rs+++ b/crates/ui-egui/src/show.rs@@ -36,6 +36,12 @@ pub struct Show {     /// The next slide's first frame, rendered in the background while this one is shown so     /// advancing only uploads it (#240).     prefetch: Option<Prefetch>,+    /// The screens the slides and Presenter View fill, when DeckCraft placed them (two screens+    /// or more); otherwise the slides fill the window's screen.+    pub screens: Option<(usize, usize)>,+    /// Where the window was (outside and inside, in points) before the slides were sent to+    /// another screen, to put it back after the show: X11 brings it back on the slides' screen.+    window_before: Option<(Rect, Rect)>,     /// The current slide's backdrop, under its animation frames.     backdrop: Option<Backdrop>,     blank: Option<Color32>,@@ -334,6 +340,8 @@ impl Show {             overlay: None,             next_tex: None,             prefetch: None,+            screens: None,+            window_before: None,             backdrop: None,             blank: None,             grid: false,@@ -870,7 +878,13 @@ 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));+            if show.screens.is_some() {+                show.window_before = ctx.input(|i| i.viewport().outer_rect.zip(i.viewport().inner_rect));+            }+            ctx.send_viewport_cmd(match show.screens {+                Some((slides, _)) => egui::ViewportCommand::SetMonitor(slides),+                None => egui::ViewportCommand::Fullscreen(true),+            });         }         show.after_enter(&doc, now);     }@@ -1416,6 +1430,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {         app.media.stop_owned(crate::media::Owner::Show);         if !show.reading {             ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));+            if let Some((outer, inner)) = show.window_before {+                ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(outer.min));+                ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner.size()));+            }         }         if show.rehearse {             if let Some((_, tl)) = &show.timeline {@@ -2188,140 +2206,140 @@ fn presenter_window(ctx: &egui::Context, show: &mut Show, doc: &Presentation, no     let button = |ui: &mut Ui, s: &str| {         ui.add(egui::Button::new(egui::RichText::new(s).size(15.0).color(Color32::WHITE)).fill(Color32::from_gray(60)).min_size(vec2(0.0, 30.0)))     };-    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]),-        |ui, _| {-            if !keyboard(ui.ctx(), show, doc, now, true) {-                *keep = false;-            }-            ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));-            let elapsed = show.timer.elapsed(now);-            let paused = show.timer.paused();-            egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| {-                ui.horizontal(|ui| {-                    ui.label(egui::RichText::new(format_elapsed(elapsed)).size(22.0).color(if paused {-                        Color32::from_gray(160)-                    } else {-                        Color32::WHITE-                    }));-                    ui.add_space(12.0);-                    ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200)));-                    ui.add_space(20.0);-                    ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200)));-                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {-                        if button(ui, "End Show").clicked() {-                            *keep = false;-                        }-                        if button(ui, "Next ▶").clicked() {-                            actions.push(show.state.next(doc));-                        }-                        if button(ui, "◀ Previous").clicked() {-                            actions.push(show.state.prev(doc));-                        }-                    });-                });-                ui.horizontal(|ui| {-                    if button(ui, if paused { "Resume" } else { "Pause" }).clicked() {-                        show.timer.toggle(now);-                    }-                    if button(ui, "Reset").clicked() {-                        show.timer.reset(now);-                    }-                    if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() {-                        toggle_blank(show, Color32::BLACK);-                    }-                    if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() {-                        toggle_blank(show, Color32::WHITE);+    let mut window = egui::ViewportBuilder::default().with_title("Presenter View — DeckCraft").with_inner_size([1100.0, 700.0]);+    if let Some((_, screen)) = show.screens {+        window = window.with_monitor(screen);+    }+    ctx.show_viewport_immediate(egui::ViewportId::from_hash_of("deckcraft_presenter"), window, |ui, _| {+        if !keyboard(ui.ctx(), show, doc, now, true) {+            *keep = false;+        }+        ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));+        let elapsed = show.timer.elapsed(now);+        let paused = show.timer.paused();+        egui::CentralPanel::default().frame(egui::Frame::NONE.fill(Color32::from_gray(24)).inner_margin(egui::Margin::same(16))).show(ui, |ui| {+            ui.horizontal(|ui| {+                ui.label(egui::RichText::new(format_elapsed(elapsed)).size(22.0).color(if paused {+                    Color32::from_gray(160)+                } else {+                    Color32::WHITE+                }));+                ui.add_space(12.0);+                ui.label(egui::RichText::new(clock.clone()).size(16.0).color(Color32::from_gray(200)));+                ui.add_space(20.0);+                ui.label(egui::RichText::new(format!("Slide {} of {}", cur + 1, doc.slides.len())).size(16.0).color(Color32::from_gray(200)));+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {+                    if button(ui, "End Show").clicked() {+                        *keep = false;                     }-                    if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() {-                        show.presenter_grid = !show.presenter_grid;+                    if button(ui, "Next ▶").clicked() {+                        actions.push(show.state.next(doc));                     }-                    if button(ui, show.zoom_label()).clicked() {-                        show.zoom_key();+                    if button(ui, "◀ Previous").clicked() {+                        actions.push(show.state.prev(doc));                     }                 });-                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.scope_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;+            });+            ui.horizontal(|ui| {+                if button(ui, if paused { "Resume" } else { "Pause" }).clicked() {+                    show.timer.toggle(now);                 }-                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 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);+                if button(ui, "Reset").clicked() {+                    show.timer.reset(now);                 }-                if let Some(z) = show.zoom {-                    let mark = zoom_frame(cr, z.uv());-                    ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside);+                if button(ui, if show.blank == Some(Color32::BLACK) { "Black on" } else { "Black" }).clicked() {+                    toggle_blank(show, Color32::BLACK);                 }-                if show.zoom_arm {-                    let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click());-                    hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom"));-                    if hit.clicked()-                        && let Some(p) = hit.interact_pointer_pos()-                        && cr.width() > 1.0-                        && cr.height() > 1.0-                    {-                        show.zoom = Some(Zoom::at((p.x - cr.min.x) / cr.width(), (p.y - cr.min.y) / cr.height()));-                        show.zoom_arm = false;-                    }+                if button(ui, if show.blank == Some(Color32::WHITE) { "White on" } else { "White" }).clicked() {+                    toggle_blank(show, Color32::WHITE);                 }-                if let Some(c) = show.blank {-                    ui.painter().rect_filled(cr, CornerRadius::ZERO, c);+                if button(ui, if show.presenter_grid { "All slides on" } else { "All slides" }).clicked() {+                    show.presenter_grid = !show.presenter_grid;                 }-                let nr = next_rect(right, doc);-                ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170));-                match &next_tex {-                    Some(t) => {-                        ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);-                    }-                    None => {-                        ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40));-                        ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170));-                    }+                if button(ui, show.zoom_label()).clicked() {+                    show.zoom_key();                 }-                let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.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() {-                        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() {-                        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));-                egui::ScrollArea::vertical().show(&mut child, |ui| {-                    presenter_notes(ui, notes, show.notes_px);-                });             });-            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;+            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.scope_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);+            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);+            }+            if let Some(z) = show.zoom {+                let mark = zoom_frame(cr, z.uv());+                ui.painter().rect_stroke(mark, CornerRadius::ZERO, Stroke::new(2.0, Color32::WHITE), egui::StrokeKind::Inside);+            }+            if show.zoom_arm {+                let hit = ui.interact(cr, ui.scope_id().with("presenter_zoom"), Sense::click());+                hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Zoom"));+                if hit.clicked()+                    && let Some(p) = hit.interact_pointer_pos()+                    && cr.width() > 1.0+                    && cr.height() > 1.0+                {+                    show.zoom = Some(Zoom::at((p.x - cr.min.x) / cr.width(), (p.y - cr.min.y) / cr.height()));+                    show.zoom_arm = false;+                }+            }+            if let Some(c) = show.blank {+                ui.painter().rect_filled(cr, CornerRadius::ZERO, c);+            }+            let nr = next_rect(right, doc);+            ui.painter().text(right.min, Align2::LEFT_TOP, "Next", theme::font(13.0), Color32::from_gray(170));+            match &next_tex {+                Some(t) => {+                    ui.painter().image(t.id(), nr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);+                }+                None => {+                    ui.painter().rect_filled(nr, CornerRadius::ZERO, Color32::from_gray(40));+                    ui.painter().text(nr.center(), Align2::CENTER_CENTER, "End of slide show", theme::font(13.0), Color32::from_gray(170));+                }+            }+            let notes_head = Rect::from_min_size(pos2(right.min.x, nr.max.y + 12.0), vec2(right.width(), 28.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() {+                    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() {+                    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));+            egui::ScrollArea::vertical().show(&mut child, |ui| {+                presenter_notes(ui, notes, show.notes_px);+            });+        });+        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;+        }+    });     for a in actions {         if !show.apply(a, doc, now) {             *keep = false;@@ -2728,6 +2746,28 @@ mod tests {         assert_eq!(got.pixels, want.pixels);     } +    /// As in PowerPoint: with two screens the slides go to the other screen and Presenter View+    /// stays with the presenter; with one screen there is no Presenter View.+    #[test]+    fn presenter_view_gets_a_screen_of_its_own() {+        use crate::Screens;+        let one = Screens { list: vec![0], current: Some(0), primary: Some(0) };+        assert_eq!(one.for_presenter_view(), None);+        // A screen Wayland lists twice reaches here once (the host merges them): still one.+        let listed_twice = Screens { list: vec![0], current: Some(0), primary: None };+        assert_eq!(listed_twice.for_presenter_view(), None);+        let two = |current, primary| Screens { list: vec![0, 1], current, primary };+        assert_eq!(two(Some(1), None).for_presenter_view(), Some((0, 1)), "Presenter View where DeckCraft is");+        assert_eq!(two(Some(0), Some(1)).for_presenter_view(), Some((1, 0)));+        assert_eq!(two(None, Some(1)).for_presenter_view(), Some((0, 1)), "else the main screen");+        assert_eq!(two(None, None).for_presenter_view(), Some((1, 0)));+        // A screen number that isn't listed is ignored.+        assert_eq!(two(Some(7), None).for_presenter_view(), Some((1, 0)));+        assert_eq!(two(Some(7), Some(1)).for_presenter_view(), Some((0, 1)), "an unlisted current falls back to the main screen");+        let three = Screens { list: vec![0, 2, 3], current: Some(2), primary: None };+        assert_eq!(three.for_presenter_view(), Some((0, 2)));+    }+     /// A glowing full-slide picture in a layout made every animation frame re-render (and re-blur)     /// the backdrop: 2–3 frames a second. Frames now draw the slide's shapes over the kept     /// backdrop, and must look the same as a whole-slide render.