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 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 fn edid_1080p60() -> Vec<u8> {
493 let mut edid = vec![0u8; 128];
494 edid[54] = 0x02;
496 edid[55] = 0x3A;
497 edid[56] = 0x80;
499 edid[57] = 0x18;
500 edid[58] = 0x71;
501 edid[59] = 0x38;
503 edid[60] = 0x2D;
504 edid[61] = 0x40;
505 edid
506 }
507
508 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 #[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 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 #[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 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 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 let mut edid = vec![0u8; 128];
586 edid[54] = 0x01; assert_eq!(parse_refresh_rate_from_edid(&edid), None);
589 }
590
591 #[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 #[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 edid[12] = 0x78;
614 edid[13] = 0x56;
615 edid[14] = 0x34;
616 edid[15] = 0x12; assert_eq!(
618 parse_serial_number_from_edid(&edid),
619 Some("305419896".to_string())
620 );
621
622 edid[72] = 0x00;
624 edid[73] = 0x00;
625 edid[74] = 0x00;
626 edid[75] = 0xFF; 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 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 edid[12] = 0x01;
659 edid[13] = 0x02;
660 edid[14] = 0x03;
661 edid[15] = 0x04;
662 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 assert_eq!(
673 parse_serial_number_from_edid(&edid),
674 Some("ASCIIWIN0001".to_string())
675 );
676 }
677
678 #[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 #[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}