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}