collective

Browse / github.com/storytold/deckcraft / patch

Show: Presenter View only with a second display

Show: Presenter View only with a second display With one display the presenter window opened on the same screen as the show. Under Hyprland's focus-follows-mouse, whichever window the pointer was over took full screen, and the hidden one stalled every frame: the show lagged, dropped key presses or froze. One display now shows only the slides; Presenter View needs a second display. Will be rebased onto #433 once it lands, using its `SlideApp::displays` instead of the separate count below (see the discussion in the comments). - `apps/deckcraft/src/displays.rs` counts displays through Wayland outputs (`wl_output`) or X11 RandR monitors, on

Status
open
Score
0.00
Adopters
0 other operators
Author
melandur (imported from pull request #1156, open)
Base
128cb0b883c1210a8aeda30c713b889f5d92a984
Size
+95 −1 in 5 files
Review
pending
Shared
2026-10-28
Id
47230d01fc7fe0df1117c984

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/deckcraft/Cargo.toml apps/deckcraft/src/displays.rs apps/deckcraft/src/main.rs crates/ui-egui/src/lib.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -906,8 +906,10 @@ dependencies = [  "log",  "rfd",  "serde_json",+ "wayland-client",  "winit",  "winresource",+ "x11rb", ]  [[package]]diff --git a/apps/deckcraft/Cargo.toml b/apps/deckcraft/Cargo.toml--- a/apps/deckcraft/Cargo.toml+++ b/apps/deckcraft/Cargo.toml@@ -38,3 +38,6 @@ fmv-macos-events = "=0.1.0"  [target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] winit = { version = "0.30", default-features = false, features = ["x11"] }+# Counting displays for Presenter View (src/displays.rs); both already come with winit.+wayland-client = "0.31"+x11rb = { version = "0.13", features = ["randr"] }diff --git a/apps/deckcraft/src/displays.rs b/apps/deckcraft/src/displays.rsnew file mode 100644--- /dev/null+++ b/apps/deckcraft/src/displays.rs@@ -0,0 +1,51 @@+//! How many displays are connected (Presenter View needs a second one), asked of the display+//! server the window uses: Wayland's outputs or X11's RandR monitors. `None` when it can't tell.++/// The connected displays, through X11 when `x11` (the window's backend) else Wayland.+pub fn count(x11: bool) -> Option<usize> {+    let n = if x11 { x11_monitors() } else { wayland_outputs() };+    n.filter(|n| *n > 0)+}++fn wayland_outputs() -> Option<usize> {+    use wayland_client::protocol::wl_registry;+    use wayland_client::{Connection, Dispatch, QueueHandle};++    struct Outputs(usize);+    impl Dispatch<wl_registry::WlRegistry, ()> for Outputs {+        fn event(state: &mut Self, _: &wl_registry::WlRegistry, event: wl_registry::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {+            if let wl_registry::Event::Global { interface, .. } = event+                && interface == "wl_output"+            {+                state.0 += 1;+            }+        }+    }++    let conn = Connection::connect_to_env().ok()?;+    let mut queue = conn.new_event_queue();+    let _registry = conn.display().get_registry(&queue.handle(), ());+    let mut outputs = Outputs(0);+    queue.roundtrip(&mut outputs).ok()?;+    Some(outputs.0)+}++fn x11_monitors() -> Option<usize> {+    use x11rb::connection::Connection;+    use x11rb::protocol::randr::ConnectionExt;++    let (conn, screen) = x11rb::connect(None).ok()?;+    let root = conn.setup().roots.get(screen)?.root;+    let reply = conn.randr_get_monitors(root, true).ok()?.reply().ok()?;+    Some(reply.monitors.len())+}++#[cfg(test)]+mod tests {+    #[test]+    fn counting_never_fails_without_a_display_server() {+        // CI has no display: the count is unknown, never a crash.+        let _ = super::count(true);+        let _ = super::count(false);+    }+}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@@ -20,6 +20,8 @@ mod audio; mod control_server; #[cfg(all(unix, not(target_os = "macos")))] mod display;+#[cfg(all(unix, not(target_os = "macos")))]+mod displays; #[cfg(any(target_os = "windows", test))] mod graphics; mod logging;@@ -256,6 +258,11 @@ fn main() -> eframe::Result {         options,         Box::new(move |cc| {             let mut app = SlideApp::new(Session::new(), services());+            // Presenter View asks the display server the window is on how many displays there are.+            #[cfg(all(unix, not(target_os = "macos")))]+            {+                app.services.displays = Some(Box::new(move || displays::count(x11)));+            }             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@@ -220,6 +220,8 @@ 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>>,+    /// How many displays are connected, when the host can tell: Presenter View needs a second.+    pub displays: Option<Box<dyn FnMut() -> Option<usize>>>, }  #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]@@ -1075,8 +1077,9 @@ impl SlideApp {     pub fn start_show(&mut self, from: usize, reading: bool) {         self.anim_preview = None;         self.media.stop_owned(media::Owner::Editor);+        let presenter = self.ui.presenter_view && !reading && self.second_display();         if let Some(d) = self.session.active() {-            self.show = Some(show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading));+            self.show = Some(show::Show::new(&d.doc, from, reading, presenter));         }     } @@ -1169,6 +1172,14 @@ impl SlideApp {         Ok(json!({"playing": true, "slide": slide, "duration": duration}))     } +    /// Whether a second display can take Presenter View. Like PowerPoint, one display shows only+    /// the slides: the presenter window would open on the same screen, and whichever of the two+    /// windows ends up behind the other stalls the show (the compositor stops pacing a hidden+    /// window). Hosts that can't count displays keep Presenter View.+    fn second_display(&mut self) -> bool {+        self.services.displays.as_mut().and_then(|f| f()).is_none_or(|n| n >= 2)+    }+     fn pick_and_open(&mut self) {         if let Some(pick) = self.services.pick_open.as_mut() {             if let Some(path) = pick("open") {@@ -2203,6 +2214,26 @@ mod tests {         assert_eq!(old.qat.len(), 6);     } +    #[test]+    fn presenter_view_needs_a_second_display() {+        // One display: the presenter window covered the show (or the show covered it) and the+        // hidden window stalled every frame.+        let presenter = |displays: Option<usize>| {+            let services = Services { displays: Some(Box::new(move || displays)), ..Default::default() };+            let mut app = SlideApp::new(Session::with_new(), services);+            app.ui.presenter_view = true;+            app.start_show(0, false);+            app.show.as_ref().map(|s| s.presenter)+        };+        assert_eq!(presenter(Some(1)), Some(false));+        assert_eq!(presenter(Some(2)), Some(true));+        assert_eq!(presenter(None), Some(true), "a host that can't count displays keeps Presenter View");+        let mut app = SlideApp::new(Session::with_new(), Services { displays: Some(Box::new(|| Some(2))), ..Default::default() });+        app.ui.presenter_view = false;+        app.start_show(0, false);+        assert_eq!(app.show.as_ref().map(|s| s.presenter), Some(false), "the setting still turns it off");+    }+     #[test]     fn opening_a_file_with_missing_fonts_shows_a_toast_once() {         use egui_kittest::kittest::Queryable;