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
@@ -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) |@@ -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 a@@ -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); } }@@ -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() {@@ -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); }@@ -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.