1#[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#[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#[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#[allow(dead_code)]
99pub fn parse_serial_number_from_edid(edid: &[u8]) -> Option<String> {
100 if edid.len() < 128 {
101 return None;
102 }
103 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 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#[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
157pub 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 #[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 #[repr(C)]
187 struct DevMode {
188 device_name: [u16; 32], spec_version: u16, driver_version: u16, size: u16, driver_extra: u16, fields: u32, position_x: i32, position_y: i32, display_orientation: u32, display_fixed_output: u32, color: u16, duplex: u16, y_resolution: u16, tt_option: u16, collate: u16, form_name: [u16; 32], log_pixels: u16, bits_per_pel: u32, pels_width: u32, pels_height: u32, display_flags: u32, display_frequency: u32, icm_method: u32, icm_intent: u32, media_type: u32, dither_type: u32, reserved1: u32, reserved2: u32, panning_width: u32, panning_height: u32, } #[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 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 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#[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#[cfg(not(any(target_os = "macos", target_os = "windows")))]
444pub fn parse_xrandr_displays(stdout: &str) -> Vec<String> {
445 parse_xrandr_displays_with(stdout, get_monitor_name_for_port)
446}
447
448pub fn parse_xrandr_displays_with(
458 stdout: &str,
459 resolve: impl Fn(&str) -> Option<String>,
460) -> Vec<String> {
461 let mut displays = Vec::new();
462 let mut current_display = None;
463 let mut current_port = None;
464 for line in stdout.lines() {
465 let line = line.trim();
466 if line.contains(" connected ") {
467 let parts: Vec<&str> = line.split_whitespace().collect();
468 if let Some(&port) = parts.first() {
469 current_port = Some(port.to_string());
470 }
471 for part in parts {
472 if part.contains('x') && part.contains('+') {
473 if let Some(res) = part.split('+').next() {
474 current_display = Some(res.to_string());
475 }
476 }
477 }
478 } else if line.contains('*') {
479 if let Some(res) = current_display.take() {
480 let port = current_port.take().unwrap_or_default();
481 let name = resolve(&port).unwrap_or_else(|| port.clone());
482 let parts: Vec<&str> = line.split_whitespace().collect();
483 let mut added = false;
484 for part in parts {
485 if part.contains('*') {
486 let freq = part.trim_end_matches(['*', '+']);
487 displays.push(format!("{} ({} @ {}Hz)", name, res, freq));
488 added = true;
489 break;
490 }
491 }
492 if !added {
493 displays.push(format!("{} ({})", name, res));
494 }
495 }
496 }
497 }
498 displays
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 fn edid_1080p60() -> Vec<u8> {
509 let mut edid = vec![0u8; 128];
510 edid[54] = 0x02;
512 edid[55] = 0x3A;
513 edid[56] = 0x80;
515 edid[57] = 0x18;
516 edid[58] = 0x71;
517 edid[59] = 0x38;
519 edid[60] = 0x2D;
520 edid[61] = 0x40;
521 edid
522 }
523
524 fn inject_monitor_name(edid: &mut Vec<u8>, name: &[u8]) {
526 edid[72] = 0x00;
527 edid[73] = 0x00;
528 edid[74] = 0x00;
529 edid[75] = 0xFC;
530 for (i, &b) in name.iter().enumerate().take(13) {
531 edid[76 + i] = b;
532 }
533 }
534
535 #[test]
538 fn test_monitor_name_too_short_edid() {
539 assert_eq!(parse_monitor_name_from_edid(&[0u8; 64]), None);
540 }
541
542 #[test]
543 fn test_monitor_name_no_descriptor() {
544 assert_eq!(parse_monitor_name_from_edid(&[0u8; 128]), None);
546 }
547
548 #[test]
549 fn test_monitor_name_at_offset_72() {
550 let mut edid = vec![0u8; 128];
551 inject_monitor_name(&mut edid, b"DELL S3422DW\n");
552 assert_eq!(
553 parse_monitor_name_from_edid(&edid),
554 Some("DELL S3422DW".to_string())
555 );
556 }
557
558 #[test]
559 fn test_monitor_name_at_offset_54() {
560 let mut edid = vec![0u8; 128];
561 edid[54] = 0x00;
562 edid[55] = 0x00;
563 edid[56] = 0x00;
564 edid[57] = 0xFC;
565 let name = b"LG 27UK850\n ";
566 for (i, &b) in name.iter().enumerate().take(13) {
567 edid[58 + i] = b;
568 }
569 assert_eq!(
570 parse_monitor_name_from_edid(&edid),
571 Some("LG 27UK850".to_string())
572 );
573 }
574
575 #[test]
578 fn test_parse_refresh_rate_from_edid() {
579 let edid = edid_1080p60();
580 let refresh = parse_refresh_rate_from_edid(&edid);
581 assert!(refresh.is_some());
582 assert_eq!(refresh.unwrap(), 60.0);
584 }
585
586 #[test]
587 fn test_refresh_rate_too_short_edid() {
588 assert_eq!(parse_refresh_rate_from_edid(&[0u8; 71]), None);
589 }
590
591 #[test]
592 fn test_refresh_rate_zero_pixel_clock() {
593 let edid = vec![0u8; 128];
595 assert_eq!(parse_refresh_rate_from_edid(&edid), None);
596 }
597
598 #[test]
599 fn test_refresh_rate_zero_totals() {
600 let mut edid = vec![0u8; 128];
602 edid[54] = 0x01; assert_eq!(parse_refresh_rate_from_edid(&edid), None);
605 }
606
607 #[test]
610 fn test_format_refresh_rate() {
611 assert_eq!(format_refresh_rate(60.0), "60");
612 assert_eq!(format_refresh_rate(59.94), "59.94");
613 assert_eq!(format_refresh_rate(143.971), "143.97");
614 assert_eq!(format_refresh_rate(120.0), "120");
615 assert_eq!(format_refresh_rate(240.0), "240");
616 }
617
618 #[test]
621 fn test_serial_too_short_edid() {
622 assert_eq!(parse_serial_number_from_edid(&[0u8; 64]), None);
623 }
624
625 #[test]
626 fn test_parse_serial_number_from_edid() {
627 let mut edid = vec![0u8; 128];
628 edid[12] = 0x78;
630 edid[13] = 0x56;
631 edid[14] = 0x34;
632 edid[15] = 0x12; assert_eq!(
634 parse_serial_number_from_edid(&edid),
635 Some("305419896".to_string())
636 );
637
638 edid[72] = 0x00;
640 edid[73] = 0x00;
641 edid[74] = 0x00;
642 edid[75] = 0xFF; let serial_str = b"CN0123456789\n";
644 for i in 0..serial_str.len() {
645 edid[76 + i] = serial_str[i];
646 }
647 assert_eq!(
648 parse_serial_number_from_edid(&edid),
649 Some("CN0123456789".to_string())
650 );
651 }
652
653 #[test]
654 fn test_serial_numeric_zero_is_none() {
655 let edid = vec![0u8; 128];
657 assert_eq!(parse_serial_number_from_edid(&edid), None);
658 }
659
660 #[test]
661 fn test_serial_numeric_all_ff_is_none() {
662 let mut edid = vec![0u8; 128];
663 edid[12] = 0xFF;
664 edid[13] = 0xFF;
665 edid[14] = 0xFF;
666 edid[15] = 0xFF;
667 assert_eq!(parse_serial_number_from_edid(&edid), None);
668 }
669
670 #[test]
671 fn test_serial_ascii_takes_precedence_over_numeric() {
672 let mut edid = vec![0u8; 128];
673 edid[12] = 0x01;
675 edid[13] = 0x02;
676 edid[14] = 0x03;
677 edid[15] = 0x04;
678 edid[54] = 0x00;
680 edid[55] = 0x00;
681 edid[56] = 0x00;
682 edid[57] = 0xFF;
683 let serial = b"ASCIIWIN0001\n";
684 for (i, &b) in serial.iter().enumerate().take(13) {
685 edid[58 + i] = b;
686 }
687 assert_eq!(
689 parse_serial_number_from_edid(&edid),
690 Some("ASCIIWIN0001".to_string())
691 );
692 }
693
694 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
697 #[test]
698 fn test_parse_xrandr_displays() {
699 let sample = "Screen 0: minimum 320 x 200, current 2560 x 1440\n\
700 DP-1 connected primary 2560x1440+0+0\n\
701 2560x1440 143.97*+\n\
702 1920x1080 60.00\n";
703 let parsed = parse_xrandr_displays_with(sample, |_| None);
704 assert_eq!(parsed, vec!["DP-1 (2560x1440 @ 143.97Hz)".to_string()]);
705 }
706
707 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
708 #[test]
709 fn test_parse_xrandr_multiple_displays() {
710 let sample = "Screen 0: minimum 320 x 200, current 3840 x 1080\n\
711 HDMI-1 connected 1920x1080+0+0\n\
712 1920x1080 60.00*+\n\
713 DP-1 connected 1920x1080+1920+0\n\
714 1920x1080 144.00*+\n";
715 let parsed = parse_xrandr_displays_with(sample, |_| None);
716 assert_eq!(
717 parsed,
718 vec![
719 "HDMI-1 (1920x1080 @ 60.00Hz)".to_string(),
720 "DP-1 (1920x1080 @ 144.00Hz)".to_string(),
721 ]
722 );
723 }
724
725 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
726 #[test]
727 fn test_parse_xrandr_resolver_substitutes_monitor_name() {
728 let sample = "Screen 0: minimum 320 x 200, current 3840 x 1080\n\
731 HDMI-1 connected 1920x1080+0+0\n\
732 1920x1080 60.00*+\n\
733 DP-1 connected 1920x1080+1920+0\n\
734 1920x1080 144.00*+\n";
735 let parsed = parse_xrandr_displays_with(sample, |port| {
736 (port == "DP-1").then(|| "DELL S3422DW".to_string())
737 });
738 assert_eq!(
739 parsed,
740 vec![
741 "HDMI-1 (1920x1080 @ 60.00Hz)".to_string(),
742 "DELL S3422DW (1920x1080 @ 144.00Hz)".to_string(),
743 ]
744 );
745 }
746
747 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
748 #[test]
749 fn test_parse_xrandr_no_connected_displays() {
750 let sample = "Screen 0: minimum 320 x 200, current 0 x 0\n\
751 HDMI-1 disconnected\n\
752 DP-1 disconnected\n";
753 assert_eq!(
754 parse_xrandr_displays_with(sample, |_| None),
755 Vec::<String>::new()
756 );
757 }
758
759 #[cfg(target_os = "macos")]
762 #[test]
763 fn test_parse_macos_displays() {
764 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";
765 let parsed = parse_macos_displays(sample);
766 assert_eq!(parsed, vec!["Color LCD (3024x1964 @ 60Hz)".to_string()]);
767 }
768
769 #[cfg(target_os = "macos")]
770 #[test]
771 fn test_parse_macos_no_frequency() {
772 let sample = "Graphics/Displays:\n Displays:\n ASUS PA329C:\n Resolution: 3840 x 2160\n";
773 let parsed = parse_macos_displays(sample);
774 assert_eq!(parsed, vec!["ASUS PA329C (3840x2160)".to_string()]);
775 }
776}