concinnity_render/display_mode.rs
1//! The backend-agnostic display-mode list behind the "Resolution" settings row.
2//! A backend enumerates the modes (width x height at refresh rate) the display
3//! it renders to supports; this module holds the shared shaping: the row/list
4//! label format, the dedup + sort that turns a raw enumeration into the menu
5//! list, the persisted-choice -> list-index recovery, and the static fallback a
6//! backend without enumeration (or an embedded view with no window) uses so the
7//! row still drives the windowed resize path.
8//!
9//! How a chosen mode is applied stays per window mode: windowed resizes the
10//! window's content area to the resolution; fullscreen switches the display to
11//! the mode itself (resolution + refresh rate); borderless always covers the
12//! display's current mode, so the row is inert there.
13
14use alloc::format;
15use alloc::string::String;
16use alloc::vec::Vec;
17
18/// One display mode the hardware supports: pixel dimensions plus refresh rate.
19/// `refresh_hz` of 0 means unknown (some built-in panels report none); the label
20/// then omits the rate and a fullscreen apply keeps the display's current rate.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub struct DisplayMode {
23 /// Width in pixels.
24 pub width: u32,
25 /// Height in pixels.
26 pub height: u32,
27 /// Refresh rate in Hz; 0 when the display reports none.
28 pub refresh_hz: u32,
29}
30
31impl DisplayMode {
32 /// The option text shown in the Resolution row and its dropdown list, e.g.
33 /// "2560 x 1440 (165Hz)"; a mode with an unknown rate reads "2560 x 1440".
34 pub fn label(&self) -> String {
35 if self.refresh_hz == 0 {
36 format!("{} x {}", self.width, self.height)
37 } else {
38 format!("{} x {} ({}Hz)", self.width, self.height, self.refresh_hz)
39 }
40 }
41}
42
43/// The menu list for a raw enumeration: duplicates collapsed, ordered by width,
44/// then height, then refresh rate ascending (each resolution's rate variants
45/// group together).
46pub fn normalize(mut modes: Vec<DisplayMode>) -> Vec<DisplayMode> {
47 modes.sort();
48 modes.dedup();
49 modes
50}
51
52/// The list index for a (possibly persisted) choice. An exact match wins; a
53/// choice whose resolution is listed but whose rate is not (the display
54/// changed) snaps to that resolution's nearest rate; otherwise the nearest
55/// resolution by pixel count, so a stale persisted mode still lands somewhere
56/// sensible. Returns 0 for an empty list (callers guard, but stay total).
57pub fn index_of(modes: &[DisplayMode], choice: DisplayMode) -> usize {
58 if let Some(i) = modes.iter().position(|m| *m == choice) {
59 return i;
60 }
61 let same_res = modes
62 .iter()
63 .enumerate()
64 .filter(|(_, m)| m.width == choice.width && m.height == choice.height)
65 .min_by_key(|(_, m)| m.refresh_hz.abs_diff(choice.refresh_hz))
66 .map(|(i, _)| i);
67 if let Some(i) = same_res {
68 return i;
69 }
70 let choice_px = u64::from(choice.width) * u64::from(choice.height);
71 modes
72 .iter()
73 .enumerate()
74 .min_by_key(|(_, m)| {
75 let px = u64::from(m.width) * u64::from(m.height);
76 (
77 px.abs_diff(choice_px),
78 m.refresh_hz.abs_diff(choice.refresh_hz),
79 )
80 })
81 .map(|(i, _)| i)
82 .unwrap_or(0)
83}
84
85/// The index in `modes` of the native mode to apply for `want`: an exact
86/// (resolution, rate) match wins; a `want` with an unknown rate (0) or a rate
87/// the display no longer offers takes the matching resolution's highest rate;
88/// `None` when no mode has that resolution (e.g. a stale persisted choice from
89/// another monitor), so the caller leaves the display alone. Used by the
90/// DirectX + Vulkan apply paths; Metal does the same matching natively over
91/// CGDisplayModes (`find_native_mode`), so this is dead on a Metal-only build.
92pub fn best_native_index(modes: &[DisplayMode], want: DisplayMode) -> Option<usize> {
93 let mut best: Option<(u32, usize)> = None;
94 for (i, m) in modes.iter().enumerate() {
95 if m.width != want.width || m.height != want.height {
96 continue;
97 }
98 if want.refresh_hz != 0 && m.refresh_hz == want.refresh_hz {
99 return Some(i);
100 }
101 if best.as_ref().is_none_or(|(hz, _)| m.refresh_hz > *hz) {
102 best = Some((m.refresh_hz, i));
103 }
104 }
105 best.map(|(_, i)| i)
106}
107
108/// The static list used when the backend cannot enumerate the display (DirectX /
109/// Vulkan today, or an embedded view with no window). Common resolutions with no
110/// rate, so the row keeps driving the windowed resize path.
111pub fn fallback_modes() -> Vec<DisplayMode> {
112 [(1280, 720), (1600, 900), (1920, 1080), (2560, 1440)]
113 .into_iter()
114 .map(|(width, height)| DisplayMode {
115 width,
116 height,
117 refresh_hz: 0,
118 })
119 .collect()
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 use alloc::vec;
127 fn mode(width: u32, height: u32, refresh_hz: u32) -> DisplayMode {
128 DisplayMode {
129 width,
130 height,
131 refresh_hz,
132 }
133 }
134
135 #[test]
136 fn label_includes_rate_only_when_known() {
137 assert_eq!(mode(2560, 1440, 165).label(), "2560 x 1440 (165Hz)");
138 assert_eq!(mode(1920, 1080, 60).label(), "1920 x 1080 (60Hz)");
139 assert_eq!(mode(1280, 720, 0).label(), "1280 x 720");
140 }
141
142 #[test]
143 fn normalize_dedups_and_groups_rates_per_resolution() {
144 let raw = vec![
145 mode(2560, 1440, 60),
146 mode(1024, 768, 120),
147 mode(1024, 768, 60),
148 mode(2560, 1440, 165),
149 mode(1024, 768, 60), // duplicate
150 mode(1280, 720, 75),
151 ];
152 let list = normalize(raw);
153 assert_eq!(
154 list,
155 vec![
156 mode(1024, 768, 60),
157 mode(1024, 768, 120),
158 mode(1280, 720, 75),
159 mode(2560, 1440, 60),
160 mode(2560, 1440, 165),
161 ]
162 );
163 }
164
165 #[test]
166 fn index_of_prefers_exact_then_rate_then_resolution() {
167 let list = vec![
168 mode(1280, 720, 60),
169 mode(1280, 720, 120),
170 mode(1920, 1080, 60),
171 mode(2560, 1440, 165),
172 ];
173 // Exact match.
174 assert_eq!(index_of(&list, mode(1920, 1080, 60)), 2);
175 // Listed resolution, unlisted rate: nearest rate for that resolution.
176 assert_eq!(index_of(&list, mode(1280, 720, 144)), 1);
177 // A fallback-preset choice (rate 0) lands on the resolution's lowest rate.
178 assert_eq!(index_of(&list, mode(1280, 720, 0)), 0);
179 // Unlisted resolution: nearest by pixel count (1600x900 sits closer to
180 // 1280x720 than to 1920x1080; 2048x1152 closer to 1920x1080).
181 assert_eq!(index_of(&list, mode(1600, 900, 60)), 0);
182 assert_eq!(index_of(&list, mode(2048, 1152, 60)), 2);
183 // Empty list stays total.
184 assert_eq!(index_of(&[], mode(1920, 1080, 60)), 0);
185 }
186
187 #[test]
188 fn best_native_index_snaps_rate_and_rejects_unknown_resolution() {
189 let list = vec![
190 mode(1280, 720, 60),
191 mode(1280, 720, 120),
192 mode(1920, 1080, 60),
193 ];
194 // Exact (resolution, rate) match wins.
195 assert_eq!(best_native_index(&list, mode(1280, 720, 120)), Some(1));
196 // Unknown wanted rate (0, a fallback preset) takes the resolution's
197 // highest rate.
198 assert_eq!(best_native_index(&list, mode(1280, 720, 0)), Some(1));
199 // A rate the display no longer offers also snaps to the highest.
200 assert_eq!(best_native_index(&list, mode(1920, 1080, 144)), Some(2));
201 // An unlisted resolution applies nothing (the display is left alone).
202 assert_eq!(best_native_index(&list, mode(2560, 1440, 60)), None);
203 }
204
205 #[test]
206 fn fallback_modes_are_sorted_rate_free_presets() {
207 let list = fallback_modes();
208 assert_eq!(normalize(list.clone()), list);
209 assert!(list.len() > 2, "must expand as a dropdown row");
210 assert!(list.iter().all(|m| m.refresh_hz == 0));
211 assert!(list.iter().any(|m| (m.width, m.height) == (1920, 1080)));
212 }
213}