Skip to main content

crcbl_shell/
monitor.rs

1//! Monitor enumeration: geometry, work area, scale and refresh rate.
2//!
3//! # Why monitor ids are a plain newtype and window ids are generational
4//!
5//! [`WindowId`](crate::WindowId) is a [`Handle`](crcbl_core::Handle) because a
6//! window is an object the engine creates, destroys and then keeps a stale
7//! reference to — the exact failure a generation counter catches. A monitor is
8//! not created by anyone; it is *observed*. The backend enumerates whatever the
9//! server advertises, and the only stale-id hazard is a monitor unplugged
10//! between [`Shell::monitors`](crate::Shell::monitors) and the call that used
11//! its id.
12//!
13//! That hazard is handled by an obligation instead of a generation: **a
14//! [`MonitorId`] is never reused within a shell session.** A backend that
15//! recycles ids (RandR output ids and Wayland registry names both can, after a
16//! hotplug cycle) must map them through its own table. The failure mode is then
17//! a clean [`ShellError::NoSuchMonitor`](crate::ShellError::NoSuchMonitor)
18//! rather than a borderless window appearing on the wrong display.
19//!
20//! # Refresh rate is millihertz
21//!
22//! Because 59.94 Hz exists, is extremely common, and is not 60. An integer
23//! millihertz value is exact for every mode any of these APIs report
24//! (`wl_output.mode` is already millihertz; RandR gives dot clock and totals;
25//! Win32 gives integer Hz, and macOS a `Double`), and it avoids putting a float
26//! in a `PartialEq`-compared struct that a settings screen will diff.
27
28use core::fmt;
29use std::collections::VecDeque;
30
31use crate::PhysicalRect;
32
33/// Identifier of a connected monitor.
34///
35/// Stable for as long as the monitor stays connected, never reused within a
36/// session. See the [module docs](self).
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
38pub struct MonitorId(pub u32);
39
40impl fmt::Display for MonitorId {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        write!(f, "monitor {}", self.0)
43    }
44}
45
46/// What the shell knows about one monitor.
47///
48/// Owned rather than borrowed: enumeration happens at startup and on hotplug,
49/// so the `String` is not worth avoiding, and borrowing from the shell would
50/// infect every consumer with a lifetime — the same argument
51/// `crcbl-hal`'s `AdapterInfo` makes.
52///
53/// Not `Eq`: [`scale_factor`](Self::scale_factor) is an `f64`. A settings
54/// screen diffing two of these gets `PartialEq`, which is the correct relation
55/// for a struct holding a float.
56#[derive(Clone, Debug, PartialEq)]
57pub struct MonitorInfo {
58    /// Stable id for this monitor within the session.
59    pub id: MonitorId,
60
61    /// Human-readable name, e.g. `"DP-2"` or `"Dell U2723QE"`.
62    ///
63    /// For settings UI and logs only. Never key behaviour off it: it is not
64    /// unique (two identical monitors report identical names on some drivers)
65    /// and it is not stable across drivers.
66    pub name: String,
67
68    /// Position and size in the desktop's device-pixel coordinate space.
69    ///
70    /// The origin is wherever the window system puts it, and coordinates are
71    /// signed because a monitor placed left of or above the primary one has
72    /// negative ones.
73    ///
74    /// On Wayland this comes from `xdg_output` logical geometry scaled back to
75    /// pixels by *that output's own* scale; a compositor without `xdg_output`
76    /// falls back to `wl_output.geometry`, whose position is logical while its
77    /// size is physical, so the two only agree at scale 1.
78    ///
79    /// **There is a limit to how meaningful this can be**, and it is worth
80    /// knowing before building a layout on it: on a desktop whose outputs run
81    /// at *different* scales there is no single device-pixel coordinate space
82    /// at all, and these rectangles will not tile — a 1× monitor beside a 2×
83    /// one has two device-pixel spaces, and only the compositor's logical space
84    /// is globally coherent. This type cannot express that, so it reports each
85    /// monitor in its own pixels. Use it to name a monitor and to tell a
86    /// settings screen which one is on the left; do not use it as a desktop
87    /// layout. [`ShellCaps::WINDOW_POSITION`](crate::ShellCaps::WINDOW_POSITION)
88    /// is the bit that says a *window* can be placed, and Wayland never sets
89    /// it.
90    pub bounds: PhysicalRect,
91
92    /// The part of [`bounds`](Self::bounds) not covered by panels, docks or
93    /// taskbars.
94    ///
95    /// What a *windowed* window should be placed within. Equal to `bounds` when
96    /// the backend cannot find out — Wayland has no protocol for it, so the
97    /// Wayland backend will report the full area and nothing may assume
98    /// otherwise.
99    pub work_area: PhysicalRect,
100
101    /// Device pixels per logical unit, e.g. `1.0`, `1.5`, `2.0`.
102    ///
103    /// Fractional values only appear when
104    /// [`ShellCaps::FRACTIONAL_SCALE`](crate::ShellCaps::FRACTIONAL_SCALE) is
105    /// set. Note that a *window's* scale factor is not necessarily any
106    /// monitor's: a window straddling two outputs gets one of them, chosen by
107    /// the compositor, and it can change while the window is dragged — which is
108    /// what [`ShellEvent::ScaleFactorChanged`](crate::ShellEvent::ScaleFactorChanged)
109    /// is for.
110    pub scale_factor: f64,
111
112    /// Refresh rate in millihertz — `60_000` for 60 Hz, `59_940` for 59.94 Hz.
113    ///
114    /// Zero when the backend cannot determine it (a virtual output, a browser
115    /// canvas). See the [module docs](self) for the unit choice.
116    pub refresh_millihertz: u32,
117
118    /// Whether the window system calls this the primary monitor.
119    ///
120    /// Wayland has no such concept, so a Wayland backend marks the first output
121    /// it sees. Use it as a default, never as a guarantee.
122    pub is_primary: bool,
123}
124
125impl MonitorInfo {
126    /// Refresh rate in hertz, for display and frame pacing.
127    #[inline]
128    #[must_use]
129    pub fn refresh_hz(&self) -> f64 {
130        f64::from(self.refresh_millihertz) / 1000.0
131    }
132
133    /// The frame period implied by [`refresh_hz`](Self::refresh_hz), or `None`
134    /// if the rate is unknown.
135    #[must_use]
136    pub fn frame_period(&self) -> Option<core::time::Duration> {
137        if self.refresh_millihertz == 0 {
138            return None;
139        }
140        Some(core::time::Duration::from_secs_f64(
141            1000.0 / f64::from(self.refresh_millihertz),
142        ))
143    }
144
145    /// The monitor's resolution in device pixels.
146    #[inline]
147    #[must_use]
148    pub const fn size(&self) -> crate::PhysicalSize {
149        self.bounds.size()
150    }
151}
152
153/// Queues a [`MonitorRemoved`](crate::ShellEvent::MonitorRemoved) for every
154/// monitor in `old` that `new` no longer lists, in `old`'s order, and returns
155/// their ids.
156///
157/// By id, which every backend keeps for as long as a display stays connected
158/// and never hands out again. The caller publishes `new` and its
159/// [`MonitorsChanged`](crate::ShellEvent::MonitorsChanged) afterwards, and
160/// closes its panels on the returned monitors in between.
161pub(crate) fn queue_removed(
162    old: &[MonitorInfo],
163    new: &[MonitorInfo],
164    queue: &mut VecDeque<crate::ShellEvent>,
165) -> Vec<MonitorId> {
166    let removed: Vec<MonitorId> = old
167        .iter()
168        .map(|monitor| monitor.id)
169        .filter(|id| !new.iter().any(|monitor| monitor.id == *id))
170        .collect();
171    queue.extend(
172        removed
173            .iter()
174            .map(|&monitor| crate::ShellEvent::MonitorRemoved { monitor }),
175    );
176    removed
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::PhysicalSize;
183
184    fn monitor() -> MonitorInfo {
185        MonitorInfo {
186            id: MonitorId(1),
187            name: "DP-2".to_string(),
188            bounds: PhysicalRect::new(0, 0, 3840, 2160),
189            work_area: PhysicalRect::new(0, 32, 3840, 2128),
190            scale_factor: 1.5,
191            refresh_millihertz: 143_998,
192            is_primary: true,
193        }
194    }
195
196    #[test]
197    fn refresh_rates_that_are_not_integers_survive() {
198        let mut info = monitor();
199        info.refresh_millihertz = 59_940;
200        assert!((info.refresh_hz() - 59.94).abs() < 1e-9);
201        let period = info.frame_period().expect("known rate");
202        assert!((period.as_secs_f64() - 1.0 / 59.94).abs() < 1e-9);
203
204        // The awkward one that motivated millihertz in the first place.
205        assert!((monitor().refresh_hz() - 143.998).abs() < 1e-9);
206    }
207
208    #[test]
209    fn an_unknown_refresh_rate_is_absent_not_zero_hz() {
210        let mut info = monitor();
211        info.refresh_millihertz = 0;
212        assert_eq!(info.frame_period(), None);
213        assert_eq!(info.refresh_hz(), 0.0);
214    }
215
216    #[test]
217    fn a_removal_is_an_id_the_new_list_lacks() {
218        let with_id = |id| MonitorInfo {
219            id: MonitorId(id),
220            ..monitor()
221        };
222        let mut queue = VecDeque::new();
223        let removed = queue_removed(
224            &[with_id(1), with_id(2), with_id(3)],
225            &[with_id(2), with_id(4)],
226            &mut queue,
227        );
228        assert_eq!(removed, [MonitorId(1), MonitorId(3)]);
229        assert_eq!(
230            Vec::from(queue),
231            [1, 3].map(|id| crate::ShellEvent::MonitorRemoved {
232                monitor: MonitorId(id)
233            })
234        );
235
236        // A monitor whose geometry changed is the same monitor.
237        let mut queue = VecDeque::new();
238        let mut moved = with_id(1);
239        moved.bounds = PhysicalRect::new(100, 0, 3840, 2160);
240        assert!(queue_removed(&[with_id(1)], &[moved], &mut queue).is_empty());
241        assert!(queue.is_empty());
242    }
243
244    #[test]
245    fn geometry_accessors_agree_with_the_bounds() {
246        let info = monitor();
247        assert_eq!(info.size(), PhysicalSize::new(3840, 2160));
248        assert_eq!(info.work_area.size(), PhysicalSize::new(3840, 2128));
249        assert_eq!(info.id.to_string(), "monitor 1");
250        assert_eq!(
251            monitor().frame_period().map(|period| period.as_millis()),
252            Some(6)
253        );
254    }
255}