Skip to main content

retch_sysinfo/
display.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Display detection and EDID parsing.
5//!
6//! This module provides cross-platform detection of connected displays,
7//! including resolution, refresh rate, and monitor name/serial extraction
8//! from raw EDID blobs.
9//!
10//! ## Platform Support
11//!
12//! - **Linux**: Reads natively from `/sys/class/drm` (status, modes, and raw EDID
13//!   bytes). Falls back to `xrandr --current` when no sysfs displays are found.
14//! - **macOS**: Parses `system_profiler SPDisplaysDataType` output.
15//! - **Windows**: Calls `EnumDisplayDevicesW` + `EnumDisplaySettingsW` via user32.dll FFI.
16
17/// Parse the monitor's human-readable name from a raw EDID binary blob.
18///
19/// Searches the four 18-byte descriptor blocks (at EDID offsets 54, 72, 90,
20/// and 108) for a Monitor Name Descriptor (tag `0xFC`) and returns the name
21/// string, or `None` if no such descriptor is found or the EDID is too short.
22#[allow(dead_code)]
23pub fn parse_monitor_name_from_edid(edid: &[u8]) -> Option<String> {
24    if edid.len() < 128 {
25        return None;
26    }
27    let offsets = [54, 72, 90, 108];
28    for &offset in &offsets {
29        if offset + 18 <= edid.len() {
30            let block = &edid[offset..offset + 18];
31            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFC {
32                let name_bytes = &block[4..17];
33                let name = String::from_utf8_lossy(name_bytes);
34                let cleaned = name.trim().replace('\0', "").to_string();
35                if !cleaned.is_empty() {
36                    return Some(cleaned);
37                }
38            }
39        }
40    }
41    None
42}
43
44/// Parse the refresh rate (in Hz) from the first Detailed Timing Descriptor
45/// (DTD) block in a raw EDID binary blob.
46///
47/// Reads the pixel clock, horizontal/vertical active and blanking values from
48/// bytes 54–71 of the EDID and computes the refresh rate as:
49/// `pixel_clock_hz / (h_total * v_total)`.
50///
51/// Returns `None` if the EDID is too short or the pixel clock is zero.
52#[allow(dead_code)]
53pub fn parse_refresh_rate_from_edid(edid: &[u8]) -> Option<f64> {
54    if edid.len() < 72 {
55        return None;
56    }
57    let block = &edid[54..72];
58    let pixel_clock = ((block[1] as u32) << 8) | (block[0] as u32);
59    if pixel_clock == 0 {
60        return None;
61    }
62    let pixel_clock_hz = pixel_clock * 10_000;
63    let h_active = (block[2] as u32) | (((block[4] as u32) & 0xF0) << 4);
64    let h_blanking = (block[3] as u32) | (((block[4] as u32) & 0x0F) << 8);
65    let v_active = (block[5] as u32) | (((block[7] as u32) & 0xF0) << 4);
66    let v_blanking = (block[6] as u32) | (((block[7] as u32) & 0x0F) << 8);
67
68    let h_total = h_active + h_blanking;
69    let v_total = v_active + v_blanking;
70    if h_total == 0 || v_total == 0 {
71        return None;
72    }
73
74    let refresh = (pixel_clock_hz as f64) / ((h_total * v_total) as f64);
75    Some((refresh * 100.0).round() / 100.0)
76}
77
78/// Format a refresh rate value for display.
79///
80/// Integers (e.g. 60.0) are rendered without decimals; non-integer values
81/// (e.g. 59.94) are rounded to two decimal places.
82#[allow(dead_code)]
83pub fn format_refresh_rate(refresh: f64) -> String {
84    if (refresh - refresh.round()).abs() < 0.01 {
85        format!("{:.0}", refresh)
86    } else {
87        format!("{:.2}", refresh)
88    }
89}
90
91/// Parse the serial number from a raw EDID binary blob.
92///
93/// First searches for an ASCII Serial Number Descriptor (tag `0xFF`) in the
94/// four 18-byte descriptor blocks at offsets 54, 72, 90, and 108. If not
95/// found, falls back to the 32-bit numeric serial number at EDID bytes 12–15.
96///
97/// Returns `None` if no serial number is found or the EDID is too short.
98#[allow(dead_code)]
99pub fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
100    if edid.len() < 128 {
101        return None;
102    }
103    // 1. Try finding ASCII Serial Number descriptor block (tag 0xFF)
104    let offsets = [54, 72, 90, 108];
105    for &offset in &offsets {
106        if offset + 18 <= edid.len() {
107            let block = &edid[offset..offset + 18];
108            if block[0] == 0x00 && block[1] == 0x00 && block[2] == 0x00 && block[3] == 0xFF {
109                let serial_bytes = &block[4..17];
110                let serial = String::from_utf8_lossy(serial_bytes);
111                let cleaned = serial.trim().replace('\0', "").to_string();
112                if !cleaned.is_empty() {
113                    return Some(cleaned);
114                }
115            }
116        }
117    }
118
119    // 2. Fallback to the 32-bit numeric serial number at offset 12-15
120    let serial_num = ((edid[15] as u32) << 24)
121        | ((edid[14] as u32) << 16)
122        | ((edid[13] as u32) << 8)
123        | (edid[12] as u32);
124    if serial_num != 0 && serial_num != 0xFFFFFFFF {
125        return Some(serial_num.to_string());
126    }
127
128    None
129}
130
131/// Look up the monitor name for a given DRM connector port name by reading
132/// the EDID from sysfs (`/sys/class/drm/<entry>/edid`).
133///
134/// Iterates `/sys/class/drm` for entries whose name ends with `port`, reads
135/// the EDID bytes, and extracts the monitor name via [`parse_monitor_name_from_edid`].
136/// Returns `None` if not found on Linux or not compiled for Linux.
137#[allow(dead_code)]
138pub fn get_monitor_name_for_port(port: &str) -> Option<String> {
139    if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
140        for entry in entries.filter_map(|e| e.ok()) {
141            let name = entry.file_name().to_string_lossy().to_string();
142            if name.ends_with(port) {
143                let edid_path = entry.path().join("edid");
144                if edid_path.exists() {
145                    if let Ok(edid_bytes) = std::fs::read(&edid_path) {
146                        if let Some(monitor_name) = parse_monitor_name_from_edid(&edid_bytes) {
147                            return Some(monitor_name);
148                        }
149                    }
150                }
151            }
152        }
153    }
154    None
155}
156
157/// Detect connected displays and return a list of human-readable strings
158/// describing each display (name, resolution, and refresh rate).
159///
160/// Platform-specific implementations:
161/// - **Linux**: Reads `/sys/class/drm` natively; falls back to `xrandr --current`.
162/// - **macOS**: Parses `system_profiler SPDisplaysDataType` output.
163/// - **Windows**: Calls `EnumDisplayDevicesW` + `EnumDisplaySettingsW` via user32.dll FFI.
164pub fn detect_displays() -> Vec<String> {
165    #[cfg(target_os = "macos")]
166    {
167        crate::macos_ffi::get_displays()
168    }
169
170    #[cfg(target_os = "windows")]
171    {
172        // Enumerate displays via Win32 EnumDisplayDevicesW + EnumDisplaySettingsW (user32.dll).
173        // This avoids spawning wmic and works on all modern Windows versions.
174        #[repr(C)]
175        struct DisplayDevice {
176            cb: u32,
177            device_name: [u16; 32],
178            device_string: [u16; 128],
179            state_flags: u32,
180            device_id: [u16; 128],
181            device_key: [u16; 128],
182        }
183
184        // Full DEVMODEW layout (220 bytes). Offsets must match winuser.h exactly
185        // to avoid stack corruption when EnumDisplaySettingsW writes into this.
186        #[repr(C)]
187        struct DevMode {
188            device_name: [u16; 32], // offset   0, 64 bytes
189            spec_version: u16,      // offset  64
190            driver_version: u16,    // offset  66
191            size: u16,              // offset  68
192            driver_extra: u16,      // offset  70
193            fields: u32,            // offset  72
194            // display union: dmPosition(8) + dmDisplayOrientation(4) + dmDisplayFixedOutput(4)
195            position_x: i32,           // offset  76
196            position_y: i32,           // offset  80
197            display_orientation: u32,  // offset  84
198            display_fixed_output: u32, // offset  88
199            // post-union fields
200            color: u16,             // offset  92
201            duplex: u16,            // offset  94
202            y_resolution: u16,      // offset  96
203            tt_option: u16,         // offset  98
204            collate: u16,           // offset 100
205            form_name: [u16; 32],   // offset 102, 64 bytes
206            log_pixels: u16,        // offset 166
207            bits_per_pel: u32,      // offset 168 (4-byte aligned)
208            pels_width: u32,        // offset 172
209            pels_height: u32,       // offset 176
210            display_flags: u32,     // offset 180
211            display_frequency: u32, // offset 184
212            // extended fields
213            icm_method: u32,     // offset 188
214            icm_intent: u32,     // offset 192
215            media_type: u32,     // offset 196
216            dither_type: u32,    // offset 200
217            reserved1: u32,      // offset 204
218            reserved2: u32,      // offset 208
219            panning_width: u32,  // offset 212
220            panning_height: u32, // offset 216
221        } // total: 220 bytes
222
223        #[link(name = "user32")]
224        extern "system" {
225            fn EnumDisplayDevicesW(
226                lpDevice: *const u16,
227                iDevNum: u32,
228                lpDisplayDevice: *mut DisplayDevice,
229                dwFlags: u32,
230            ) -> i32;
231
232            fn EnumDisplaySettingsW(
233                lpszDeviceName: *const u16,
234                iModeNum: u32,
235                lpDevMode: *mut DevMode,
236            ) -> i32;
237        }
238
239        const DISPLAY_DEVICE_ACTIVE: u32 = 0x00000001;
240        const ENUM_CURRENT_SETTINGS: u32 = 0xFFFF_FFFF;
241
242        fn u16_to_string(buf: &[u16]) -> String {
243            let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
244            String::from_utf16_lossy(&buf[..len])
245        }
246
247        let mut displays = Vec::new();
248        let mut dev_num = 0u32;
249        loop {
250            let mut dd = DisplayDevice {
251                cb: std::mem::size_of::<DisplayDevice>() as u32,
252                device_name: [0u16; 32],
253                device_string: [0u16; 128],
254                state_flags: 0,
255                device_id: [0u16; 128],
256                device_key: [0u16; 128],
257            };
258            let ok = unsafe { EnumDisplayDevicesW(std::ptr::null(), dev_num, &mut dd, 0) };
259            if ok == 0 {
260                break;
261            }
262            dev_num += 1;
263
264            if dd.state_flags & DISPLAY_DEVICE_ACTIVE == 0 {
265                continue;
266            }
267
268            let adapter_name = u16_to_string(&dd.device_string);
269
270            let mut dm = unsafe { std::mem::zeroed::<DevMode>() };
271            dm.size = std::mem::size_of::<DevMode>() as u16;
272            let settings_ok = unsafe {
273                EnumDisplaySettingsW(dd.device_name.as_ptr(), ENUM_CURRENT_SETTINGS, &mut dm)
274            };
275
276            let entry = if settings_ok != 0 && dm.pels_width > 0 && dm.pels_height > 0 {
277                if dm.display_frequency > 0 {
278                    format!(
279                        "{} ({}x{} @ {}Hz)",
280                        adapter_name, dm.pels_width, dm.pels_height, dm.display_frequency
281                    )
282                } else {
283                    format!("{} ({}x{})", adapter_name, dm.pels_width, dm.pels_height)
284                }
285            } else {
286                adapter_name
287            };
288
289            if !entry.is_empty() {
290                displays.push(entry);
291            }
292        }
293
294        displays
295    }
296
297    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
298    {
299        let mut displays = Vec::new();
300
301        // Try reading directly from sysfs first for best performance
302        if let Ok(entries) = std::fs::read_dir("/sys/class/drm") {
303            for entry in entries.filter_map(|e| e.ok()) {
304                let path = entry.path();
305                let status_path = path.join("status");
306                let modes_path = path.join("modes");
307                let edid_path = path.join("edid");
308                if status_path.exists() && modes_path.exists() {
309                    if let Ok(status) = std::fs::read_to_string(&status_path) {
310                        if status.trim() == "connected" {
311                            if let Ok(modes) = std::fs::read_to_string(&modes_path) {
312                                if let Some(first_mode) = modes.lines().next() {
313                                    let res = first_mode.trim().to_string();
314                                    let port = entry.file_name().to_string_lossy().to_string();
315                                    let clean_port = if let Some(idx) = port.find('-') {
316                                        port[idx + 1..].to_string()
317                                    } else {
318                                        port
319                                    };
320
321                                    let edid_bytes = if edid_path.exists() {
322                                        std::fs::read(&edid_path).ok()
323                                    } else {
324                                        None
325                                    };
326
327                                    let name = edid_bytes
328                                        .as_ref()
329                                        .and_then(|bytes| parse_monitor_name_from_edid(bytes))
330                                        .unwrap_or(clean_port);
331
332                                    let refresh = edid_bytes
333                                        .as_ref()
334                                        .and_then(|bytes| parse_refresh_rate_from_edid(bytes));
335
336                                    let serial = edid_bytes
337                                        .as_ref()
338                                        .and_then(|bytes| parse_serial_number_from_edid(bytes));
339
340                                    let display_name = if let Some(ref s) = serial {
341                                        format!("{} #{}", name, s)
342                                    } else {
343                                        name
344                                    };
345
346                                    if let Some(r) = refresh {
347                                        displays.push(format!(
348                                            "{} ({} @ {}Hz)",
349                                            display_name,
350                                            res,
351                                            format_refresh_rate(r)
352                                        ));
353                                    } else {
354                                        displays.push(format!("{} ({})", display_name, res));
355                                    }
356                                }
357                            }
358                        }
359                    }
360                }
361            }
362        }
363
364        // Fallback to xrandr only if sysfs yielded no connected displays
365        if displays.is_empty() {
366            if let Ok(output) = std::process::Command::new("xrandr")
367                .arg("--current")
368                .output()
369            {
370                if let Ok(stdout) = String::from_utf8(output.stdout) {
371                    displays = parse_xrandr_displays(&stdout);
372                }
373            }
374        }
375
376        displays
377    }
378}
379
380/// Parse connected display information from `system_profiler SPDisplaysDataType` output.
381///
382/// Returns a list of strings in the form `"<Name> (<Resolution> @ <Rate>)"`.
383#[cfg(target_os = "macos")]
384pub fn parse_macos_displays(stdout: &str) -> Vec<String> {
385    let mut displays = Vec::new();
386    let mut current_name = None;
387    let mut current_res = None;
388    let mut in_displays = false;
389
390    for line in stdout.lines() {
391        let trimmed = line.trim();
392        let indent = line.len() - line.trim_start().len();
393
394        if trimmed.starts_with("Displays:") {
395            in_displays = true;
396            continue;
397        }
398
399        if in_displays {
400            if indent < 8 && !trimmed.is_empty() && !trimmed.starts_with("Displays:") {
401                in_displays = false;
402                continue;
403            }
404
405            if trimmed.ends_with(':') && !trimmed.starts_with("UI Looks like:") {
406                let name = trimmed.trim_end_matches(':').trim().to_string();
407                current_name = Some(name);
408            } else if trimmed.starts_with("Resolution:") {
409                let res = trimmed.strip_prefix("Resolution:").unwrap_or("").trim();
410                let cleaned = res.replace(" ", "");
411                current_res = Some(cleaned);
412            } else if trimmed.starts_with("UI Looks like:") {
413                if let Some(res) = current_res.take() {
414                    let name_str = current_name.take().unwrap_or_else(|| "Display".to_string());
415                    if let Some(idx) = trimmed.find('@') {
416                        let freq = trimmed[idx..].trim();
417                        let freq_clean = freq.replace(" ", "").replace(".00", "");
418                        displays.push(format!(
419                            "{} ({} @ {})",
420                            name_str,
421                            res,
422                            freq_clean.trim_start_matches('@')
423                        ));
424                    } else {
425                        displays.push(format!("{} ({})", name_str, res));
426                    }
427                }
428            }
429        }
430    }
431    if let Some(res) = current_res {
432        let name_str = current_name.unwrap_or_else(|| "Display".to_string());
433        displays.push(format!("{} ({})", name_str, res));
434    }
435    displays
436}
437
438/// Parse connected display information from `xrandr --current` output.
439///
440/// Returns a list of strings in the form `"<Name> (<Resolution> @ <Rate>Hz)"`.
441/// For each connected port, looks up the monitor name from EDID via
442/// [`get_monitor_name_for_port`], falling back to the port name itself.
443#[cfg(not(any(target_os = "macos", target_os = "windows")))]
444pub fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
445    let mut displays = Vec::new();
446    let mut current_display = None;
447    let mut current_port = None;
448    for line in stdout.lines() {
449        let line = line.trim();
450        if line.contains(" connected ") {
451            let parts: Vec<&str> = line.split_whitespace().collect();
452            if let Some(&port) = parts.first() {
453                current_port = Some(port.to_string());
454            }
455            for part in parts {
456                if part.contains('x') && part.contains('+') {
457                    if let Some(res) = part.split('+').next() {
458                        current_display = Some(res.to_string());
459                    }
460                }
461            }
462        } else if line.contains('*') {
463            if let Some(res) = current_display.take() {
464                let port = current_port.take().unwrap_or_default();
465                let name = get_monitor_name_for_port(&port).unwrap_or_else(|| port.clone());
466                let parts: Vec<&str> = line.split_whitespace().collect();
467                let mut added = false;
468                for part in parts {
469                    if part.contains('*') {
470                        let freq = part.trim_end_matches(['*', '+']);
471                        displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
472                        added = true;
473                        break;
474                    }
475                }
476                if !added {
477                    displays.push(format!("{} ({})", name, res));
478                }
479            }
480        }
481    }
482    displays
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    // ── EDID helpers ─────────────────────────────────────────────────────────
490
491    /// Build a 128-byte all-zero EDID with the 1080p60 DTD at offset 54.
492    fn edid_1080p60() -> Vec<u8> {
493        let mut edid = vec![0u8; 128];
494        // Pixel clock 14850 (x 10 kHz = 148.5 MHz)
495        edid[54] = 0x02;
496        edid[55] = 0x3A;
497        // H Active 1920, H Blanking 280
498        edid[56] = 0x80;
499        edid[57] = 0x18;
500        edid[58] = 0x71;
501        // V Active 1080, V Blanking 45
502        edid[59] = 0x38;
503        edid[60] = 0x2D;
504        edid[61] = 0x40;
505        edid
506    }
507
508    /// Inject a Monitor Name Descriptor (tag 0xFC) at EDID offset 72.
509    fn inject_monitor_name(edid: &mut Vec<u8>, name: &[u8]) {
510        edid[72] = 0x00;
511        edid[73] = 0x00;
512        edid[74] = 0x00;
513        edid[75] = 0xFC;
514        for (i, &b) in name.iter().enumerate().take(13) {
515            edid[76 + i] = b;
516        }
517    }
518
519    // ── parse_monitor_name_from_edid ──────────────────────────────────────────
520
521    #[test]
522    fn test_monitor_name_too_short_edid() {
523        assert_eq!(parse_monitor_name_from_edid(&[0u8; 64]), None);
524    }
525
526    #[test]
527    fn test_monitor_name_no_descriptor() {
528        // 128-byte EDID with no 0xFC descriptor block -> None
529        assert_eq!(parse_monitor_name_from_edid(&[0u8; 128]), None);
530    }
531
532    #[test]
533    fn test_monitor_name_at_offset_72() {
534        let mut edid = vec![0u8; 128];
535        inject_monitor_name(&mut edid, b"DELL S3422DW\n");
536        assert_eq!(
537            parse_monitor_name_from_edid(&edid),
538            Some("DELL S3422DW".to_string())
539        );
540    }
541
542    #[test]
543    fn test_monitor_name_at_offset_54() {
544        let mut edid = vec![0u8; 128];
545        edid[54] = 0x00;
546        edid[55] = 0x00;
547        edid[56] = 0x00;
548        edid[57] = 0xFC;
549        let name = b"LG 27UK850\n  ";
550        for (i, &b) in name.iter().enumerate().take(13) {
551            edid[58 + i] = b;
552        }
553        assert_eq!(
554            parse_monitor_name_from_edid(&edid),
555            Some("LG 27UK850".to_string())
556        );
557    }
558
559    // ── parse_refresh_rate_from_edid ──────────────────────────────────────────
560
561    #[test]
562    fn test_parse_refresh_rate_from_edid() {
563        let edid = edid_1080p60();
564        let refresh = parse_refresh_rate_from_edid(&edid);
565        assert!(refresh.is_some());
566        // 148,500,000 / ((1920 + 280) * (1080 + 45)) = 60 Hz
567        assert_eq!(refresh.unwrap(), 60.0);
568    }
569
570    #[test]
571    fn test_refresh_rate_too_short_edid() {
572        assert_eq!(parse_refresh_rate_from_edid(&[0u8; 71]), None);
573    }
574
575    #[test]
576    fn test_refresh_rate_zero_pixel_clock() {
577        // All-zero bytes -> pixel clock == 0 -> None
578        let edid = vec![0u8; 128];
579        assert_eq!(parse_refresh_rate_from_edid(&edid), None);
580    }
581
582    #[test]
583    fn test_refresh_rate_zero_totals() {
584        // Non-zero pixel clock but all dimension bytes zero -> h_total == 0 -> None
585        let mut edid = vec![0u8; 128];
586        edid[54] = 0x01; // pixel clock byte != 0
587                         // all active/blanking bytes remain 0
588        assert_eq!(parse_refresh_rate_from_edid(&edid), None);
589    }
590
591    // ── format_refresh_rate ───────────────────────────────────────────────────
592
593    #[test]
594    fn test_format_refresh_rate() {
595        assert_eq!(format_refresh_rate(60.0), "60");
596        assert_eq!(format_refresh_rate(59.94), "59.94");
597        assert_eq!(format_refresh_rate(143.971), "143.97");
598        assert_eq!(format_refresh_rate(120.0), "120");
599        assert_eq!(format_refresh_rate(240.0), "240");
600    }
601
602    // ── parse_serial_number_from_edid ─────────────────────────────────────────
603
604    #[test]
605    fn test_serial_too_short_edid() {
606        assert_eq!(parse_serial_number_from_edid(&[0u8; 64]), None);
607    }
608
609    #[test]
610    fn test_parse_serial_number_from_edid() {
611        let mut edid = vec![0u8; 128];
612        // 1. Fallback 32-bit numeric serial
613        edid[12] = 0x78;
614        edid[13] = 0x56;
615        edid[14] = 0x34;
616        edid[15] = 0x12; // 0x12345678 = 305419896
617        assert_eq!(
618            parse_serial_number_from_edid(&edid),
619            Some("305419896".to_string())
620        );
621
622        // 2. ASCII Monitor Serial Number descriptor block (tag 0xFF) at offset 72
623        edid[72] = 0x00;
624        edid[73] = 0x00;
625        edid[74] = 0x00;
626        edid[75] = 0xFF; // ASCII Serial Number descriptor tag
627        let serial_str = b"CN0123456789\n";
628        for i in 0..serial_str.len() {
629            edid[76 + i] = serial_str[i];
630        }
631        assert_eq!(
632            parse_serial_number_from_edid(&edid),
633            Some("CN0123456789".to_string())
634        );
635    }
636
637    #[test]
638    fn test_serial_numeric_zero_is_none() {
639        // All-zero EDID -> serial_num == 0 -> None
640        let edid = vec![0u8; 128];
641        assert_eq!(parse_serial_number_from_edid(&edid), None);
642    }
643
644    #[test]
645    fn test_serial_numeric_all_ff_is_none() {
646        let mut edid = vec![0u8; 128];
647        edid[12] = 0xFF;
648        edid[13] = 0xFF;
649        edid[14] = 0xFF;
650        edid[15] = 0xFF;
651        assert_eq!(parse_serial_number_from_edid(&edid), None);
652    }
653
654    #[test]
655    fn test_serial_ascii_takes_precedence_over_numeric() {
656        let mut edid = vec![0u8; 128];
657        // Non-zero numeric serial
658        edid[12] = 0x01;
659        edid[13] = 0x02;
660        edid[14] = 0x03;
661        edid[15] = 0x04;
662        // ASCII serial descriptor at offset 54
663        edid[54] = 0x00;
664        edid[55] = 0x00;
665        edid[56] = 0x00;
666        edid[57] = 0xFF;
667        let serial = b"ASCIIWIN0001\n";
668        for (i, &b) in serial.iter().enumerate().take(13) {
669            edid[58 + i] = b;
670        }
671        // ASCII descriptor should win over the numeric value
672        assert_eq!(
673            parse_serial_number_from_edid(&edid),
674            Some("ASCIIWIN0001".to_string())
675        );
676    }
677
678    // ── parse_xrandr_displays ─────────────────────────────────────────────────
679
680    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
681    #[test]
682    fn test_parse_xrandr_displays() {
683        let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\n\
684                      DP-1 connected primary 2560x1440+0+0\n\
685                         2560x1440     143.97*+\n\
686                         1920x1080     60.00\n";
687        let parsed = parse_xrandr_displays(sample);
688        assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
689    }
690
691    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
692    #[test]
693    fn test_parse_xrandr_multiple_displays() {
694        let sample = "Screen 0: minimum 320 x 200, current 3840 x 1080\n\
695                      HDMI-1 connected 1920x1080+0+0\n\
696                         1920x1080     60.00*+\n\
697                      DP-1 connected 1920x1080+1920+0\n\
698                         1920x1080    144.00*+\n";
699        let parsed = parse_xrandr_displays(sample);
700        assert_eq!(
701            parsed,
702            vec![
703                "HDMI-1 (1920x1080 @ 60.00Hz)".to_string(),
704                "DP-1 (1920x1080 @ 144.00Hz)".to_string(),
705            ]
706        );
707    }
708
709    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
710    #[test]
711    fn test_parse_xrandr_no_connected_displays() {
712        let sample = "Screen 0: minimum 320 x 200, current 0 x 0\n\
713                      HDMI-1 disconnected\n\
714                      DP-1 disconnected\n";
715        assert_eq!(parse_xrandr_displays(sample), Vec::<String>::new());
716    }
717
718    // ── parse_macos_displays ──────────────────────────────────────────────────
719
720    #[cfg(target_os = "macos")]
721    #[test]
722    fn test_parse_macos_displays() {
723        let sample = "Graphics/Displays:\n\n    Apple M2:\n\n      Chipset Model: Apple M2\n      Displays:\n        Color LCD:\n          Resolution: 3024 x 1964\n          UI Looks like: 1512 x 982 @ 60.00Hz\n";
724        let parsed = parse_macos_displays(sample);
725        assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
726    }
727
728    #[cfg(target_os = "macos")]
729    #[test]
730    fn test_parse_macos_no_frequency() {
731        let sample = "Graphics/Displays:\n      Displays:\n        ASUS PA329C:\n          Resolution: 3840 x 2160\n";
732        let parsed = parse_macos_displays(sample);
733        assert_eq!(parsed, vec!["ASUS PA329C (3840x2160)".to_string()]);
734    }
735}