Skip to main content

ferrisgrid_capture/
lib.rs

1use ferrisgrid_core::{
2    CaptureBackend, CaptureTarget, CapturedScreen, ErrorKind, FerrisError, ImageFormat,
3    ImageSizeLimit, Result, ScreenInfo,
4};
5use image::{DynamicImage, ImageReader, Rgba, RgbaImage, imageops::FilterType};
6use std::fs;
7use std::path::{Path, PathBuf};
8#[cfg(any(target_os = "linux", target_os = "macos"))]
9use std::process::Command;
10
11pub struct FakeCaptureBackend;
12
13impl FakeCaptureBackend {
14    pub fn new() -> Self {
15        Self
16    }
17}
18
19impl Default for FakeCaptureBackend {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl CaptureBackend for FakeCaptureBackend {
26    fn name(&self) -> &'static str {
27        "fake"
28    }
29
30    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
31        Ok(fake_screens())
32    }
33
34    fn capture(
35        &self,
36        target: CaptureTarget,
37        frame_dir: &Path,
38        format: &ImageFormat,
39        grid_overlay: bool,
40        image_size_limit: ImageSizeLimit,
41    ) -> Result<Vec<CapturedScreen>> {
42        let screens = select_screens(fake_screens(), target)?;
43        write_fake_captures(screens, frame_dir, format, grid_overlay, image_size_limit)
44    }
45}
46
47pub struct MacOsCaptureBackend;
48
49impl CaptureBackend for MacOsCaptureBackend {
50    fn name(&self) -> &'static str {
51        "native-macos"
52    }
53
54    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
55        Ok(native_screens()?
56            .into_iter()
57            .map(|screen| screen.info)
58            .collect())
59    }
60
61    fn capture(
62        &self,
63        target: CaptureTarget,
64        frame_dir: &Path,
65        format: &ImageFormat,
66        grid_overlay: bool,
67        image_size_limit: ImageSizeLimit,
68    ) -> Result<Vec<CapturedScreen>> {
69        #[cfg(target_os = "macos")]
70        {
71            let screens = select_native_screens(native_screens()?, target)?;
72            fs::create_dir_all(frame_dir)?;
73            let mut captured = Vec::new();
74            for screen in screens {
75                let screenshot_path =
76                    frame_dir.join(format!("{}.{}", screen.info.screen_id, format.extension()));
77                capture_macos_display(screen.capture_display_index, &screenshot_path, format)?;
78                downsample_image(&screenshot_path, image_size_limit)?;
79                if grid_overlay {
80                    apply_grid_overlay(&screenshot_path)?;
81                }
82                let (image_width, image_height) = image_dimensions(&screenshot_path)
83                    .unwrap_or((screen.info.native_width, screen.info.native_height));
84                let metadata_path = write_metadata(
85                    frame_dir,
86                    &screen.info,
87                    &screenshot_path,
88                    image_width,
89                    image_height,
90                )?;
91                captured.push(CapturedScreen {
92                    image_width,
93                    image_height,
94                    screen: screen.info,
95                    screenshot_path,
96                    metadata_path,
97                });
98            }
99            Ok(captured)
100        }
101        #[cfg(not(target_os = "macos"))]
102        {
103            let _ = (target, frame_dir, format, grid_overlay, image_size_limit);
104            Err(FerrisError::new(
105                ErrorKind::Platform,
106                "native backend is currently implemented for macOS only; use --backend fake for local protocol tests",
107            ))
108        }
109    }
110}
111
112pub struct LinuxCaptureBackend;
113
114impl CaptureBackend for LinuxCaptureBackend {
115    fn name(&self) -> &'static str {
116        "native-linux-x11"
117    }
118
119    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
120        linux_screens()
121    }
122
123    fn capture(
124        &self,
125        target: CaptureTarget,
126        frame_dir: &Path,
127        format: &ImageFormat,
128        grid_overlay: bool,
129        image_size_limit: ImageSizeLimit,
130    ) -> Result<Vec<CapturedScreen>> {
131        #[cfg(target_os = "linux")]
132        {
133            let screens = select_screens(linux_screens()?, target)?;
134            fs::create_dir_all(frame_dir)?;
135
136            let root_path = frame_dir.join("root-capture.png");
137            capture_linux_root(&root_path)?;
138            let root_image = ImageReader::open(&root_path)
139                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
140                .with_guessed_format()
141                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
142                .decode()
143                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
144
145            let mut captured = Vec::new();
146            for screen in screens {
147                let x = screen.origin_x.max(0) as u32;
148                let y = screen.origin_y.max(0) as u32;
149                if x >= root_image.width() || y >= root_image.height() {
150                    return Err(FerrisError::new(
151                        ErrorKind::Capture,
152                        format!(
153                            "screen {} origin {},{} is outside root image {}x{}",
154                            screen.screen_id,
155                            screen.origin_x,
156                            screen.origin_y,
157                            root_image.width(),
158                            root_image.height()
159                        ),
160                    ));
161                }
162                let crop_width = screen
163                    .native_width
164                    .min(root_image.width().saturating_sub(x))
165                    .max(1);
166                let crop_height = screen
167                    .native_height
168                    .min(root_image.height().saturating_sub(y))
169                    .max(1);
170                let screenshot_path =
171                    frame_dir.join(format!("{}.{}", screen.screen_id, format.extension()));
172                let cropped = root_image.crop_imm(x, y, crop_width, crop_height);
173                cropped
174                    .save(&screenshot_path)
175                    .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
176                downsample_image(&screenshot_path, image_size_limit)?;
177                if grid_overlay {
178                    apply_grid_overlay(&screenshot_path)?;
179                }
180                let (image_width, image_height) = image_dimensions(&screenshot_path)
181                    .unwrap_or((screen.native_width, screen.native_height));
182                let metadata_path = write_metadata(
183                    frame_dir,
184                    &screen,
185                    &screenshot_path,
186                    image_width,
187                    image_height,
188                )?;
189                captured.push(CapturedScreen {
190                    screen,
191                    image_width,
192                    image_height,
193                    screenshot_path,
194                    metadata_path,
195                });
196            }
197            let _ = fs::remove_file(root_path);
198            Ok(captured)
199        }
200        #[cfg(not(target_os = "linux"))]
201        {
202            let _ = (target, frame_dir, format, grid_overlay, image_size_limit);
203            Err(FerrisError::new(
204                ErrorKind::Platform,
205                "native Linux X11 capture is only available on Linux; use --backend native on this OS or --backend fake",
206            ))
207        }
208    }
209}
210
211pub struct WindowsCaptureBackend;
212
213impl CaptureBackend for WindowsCaptureBackend {
214    fn name(&self) -> &'static str {
215        "native-windows"
216    }
217
218    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
219        windows_screens()
220    }
221
222    fn capture(
223        &self,
224        target: CaptureTarget,
225        frame_dir: &Path,
226        format: &ImageFormat,
227        grid_overlay: bool,
228        image_size_limit: ImageSizeLimit,
229    ) -> Result<Vec<CapturedScreen>> {
230        #[cfg(target_os = "windows")]
231        {
232            let screens = select_screens(windows_screens()?, target)?;
233            fs::create_dir_all(frame_dir)?;
234            let mut captured = Vec::new();
235            for screen in screens {
236                let screenshot_path =
237                    frame_dir.join(format!("{}.{}", screen.screen_id, format.extension()));
238                capture_windows_display(&screen, &screenshot_path)?;
239                downsample_image(&screenshot_path, image_size_limit)?;
240                if grid_overlay {
241                    apply_grid_overlay(&screenshot_path)?;
242                }
243                let (image_width, image_height) = image_dimensions(&screenshot_path)?;
244                let metadata_path = write_metadata(
245                    frame_dir,
246                    &screen,
247                    &screenshot_path,
248                    image_width,
249                    image_height,
250                )?;
251                captured.push(CapturedScreen {
252                    screen,
253                    image_width,
254                    image_height,
255                    screenshot_path,
256                    metadata_path,
257                });
258            }
259            Ok(captured)
260        }
261        #[cfg(not(target_os = "windows"))]
262        {
263            let _ = (target, frame_dir, format, grid_overlay, image_size_limit);
264            Err(FerrisError::new(
265                ErrorKind::Platform,
266                "native Windows capture is only available on Windows; use --backend native on this OS or --backend fake",
267            ))
268        }
269    }
270}
271
272pub fn backend_by_name(name: &str) -> Box<dyn CaptureBackend> {
273    match name {
274        "fake" => Box::new(FakeCaptureBackend),
275        "native" => native_backend(),
276        "macos" | "native-macos" => Box::new(MacOsCaptureBackend),
277        "linux" | "x11" | "native-linux" | "native-linux-x11" => Box::new(LinuxCaptureBackend),
278        "windows" | "win32" | "native-windows" => Box::new(WindowsCaptureBackend),
279        _ => native_backend(),
280    }
281}
282
283fn native_backend() -> Box<dyn CaptureBackend> {
284    #[cfg(target_os = "linux")]
285    {
286        Box::new(LinuxCaptureBackend)
287    }
288    #[cfg(target_os = "macos")]
289    {
290        Box::new(MacOsCaptureBackend)
291    }
292    #[cfg(target_os = "windows")]
293    {
294        Box::new(WindowsCaptureBackend)
295    }
296    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
297    {
298        Box::new(MacOsCaptureBackend)
299    }
300}
301
302#[derive(Clone)]
303#[allow(dead_code)]
304struct NativeScreen {
305    info: ScreenInfo,
306    capture_display_index: usize,
307}
308
309#[cfg(target_os = "macos")]
310fn native_screens() -> Result<Vec<NativeScreen>> {
311    let display_ids = display_ids()?;
312    if display_ids.is_empty() {
313        return Err(FerrisError::new(
314            ErrorKind::Capture,
315            "CoreGraphics returned no displays; run FerrisGrid from a logged-in desktop session with screen access",
316        ));
317    }
318
319    let main_display = unsafe { CGMainDisplayID() };
320    let mut screens = display_ids
321        .iter()
322        .enumerate()
323        .map(|(index, display_id)| {
324            let bounds = unsafe { CGDisplayBounds(*display_id) };
325            let native_width = unsafe { CGDisplayPixelsWide(*display_id) as u32 };
326            let native_height = unsafe { CGDisplayPixelsHigh(*display_id) as u32 };
327            let scale_factor = if bounds.size.width > 0.0 {
328                native_width as f64 / bounds.size.width
329            } else {
330                1.0
331            } as f32;
332            NativeScreen {
333                info: ScreenInfo {
334                    screen_id: String::new(),
335                    name: if *display_id == main_display {
336                        "Main Display".to_string()
337                    } else {
338                        format!("Display {}", index + 1)
339                    },
340                    is_primary: *display_id == main_display,
341                    origin_x: bounds.origin.x.round() as i32,
342                    origin_y: bounds.origin.y.round() as i32,
343                    native_width,
344                    native_height,
345                    scale_factor,
346                },
347                // screencapture uses 1 for the main display and subsequent display numbers
348                // for additional active displays.
349                capture_display_index: index + 1,
350            }
351        })
352        .collect::<Vec<_>>();
353
354    screens.sort_by_key(|screen| {
355        (
356            !screen.info.is_primary,
357            screen.info.origin_y,
358            screen.info.origin_x,
359        )
360    });
361    for (index, screen) in screens.iter_mut().enumerate() {
362        screen.info.screen_id = format!("screen-{}", index + 1);
363        screen.capture_display_index = index + 1;
364        if !screen.info.is_primary {
365            screen.info.name = format!("Display {}", index + 1);
366        }
367    }
368
369    Ok(screens)
370}
371
372#[cfg(target_os = "macos")]
373fn display_ids() -> Result<Vec<u32>> {
374    let mut ids = [0_u32; 32];
375    let mut count = 0_u32;
376    let active_error =
377        unsafe { CGGetActiveDisplayList(ids.len() as u32, ids.as_mut_ptr(), &mut count) };
378    if active_error == 0 && count > 0 {
379        return Ok(ids[..count as usize].to_vec());
380    }
381
382    count = 0;
383    let online_error =
384        unsafe { CGGetOnlineDisplayList(ids.len() as u32, ids.as_mut_ptr(), &mut count) };
385    if online_error != 0 {
386        return Err(FerrisError::new(
387            ErrorKind::Capture,
388            format!(
389                "CoreGraphics display discovery failed: active={active_error} online={online_error}"
390            ),
391        ));
392    }
393    Ok(ids[..count as usize].to_vec())
394}
395
396#[cfg(not(target_os = "macos"))]
397fn native_screens() -> Result<Vec<NativeScreen>> {
398    Err(FerrisError::new(
399        ErrorKind::Platform,
400        "native backend is currently implemented for macOS only; use --backend fake for local protocol tests",
401    ))
402}
403
404#[cfg(target_os = "linux")]
405fn linux_screens() -> Result<Vec<ScreenInfo>> {
406    let mut screens = run_output("xrandr", &["--query"])
407        .map(|output| parse_xrandr_screens(&output))
408        .unwrap_or_default();
409    if screens.is_empty() {
410        screens = run_output("xdpyinfo", &[])
411            .map(|output| parse_xdpyinfo_screens(&output))
412            .unwrap_or_default();
413    }
414    if screens.is_empty() {
415        return Err(FerrisError::new(
416            ErrorKind::Capture,
417            "could not discover an X11 screen; ensure DISPLAY is set and xrandr or xdpyinfo is installed",
418        ));
419    }
420    Ok(screens)
421}
422
423#[cfg(target_os = "windows")]
424fn windows_screens() -> Result<Vec<ScreenInfo>> {
425    set_windows_dpi_awareness();
426    let mut screens = Vec::<ScreenInfo>::new();
427    let success = unsafe {
428        EnumDisplayMonitors(
429            std::ptr::null_mut(),
430            std::ptr::null(),
431            Some(collect_windows_monitor),
432            (&mut screens as *mut Vec<ScreenInfo>) as isize,
433        )
434    };
435    if success == 0 {
436        return Err(windows_capture_error("EnumDisplayMonitors failed"));
437    }
438    if screens.is_empty() {
439        return Err(FerrisError::new(
440            ErrorKind::Capture,
441            "Windows returned no displays; run FerrisGrid from a logged-in interactive desktop session",
442        ));
443    }
444    Ok(normalize_screen_order(screens))
445}
446
447#[cfg(any(target_os = "windows", test))]
448fn normalize_screen_order(mut screens: Vec<ScreenInfo>) -> Vec<ScreenInfo> {
449    screens.sort_by_key(|screen| {
450        (
451            !screen.is_primary,
452            screen.origin_y,
453            screen.origin_x,
454            screen.name.clone(),
455        )
456    });
457    for (index, screen) in screens.iter_mut().enumerate() {
458        screen.screen_id = format!("screen-{}", index + 1);
459    }
460    screens
461}
462
463#[cfg(not(target_os = "windows"))]
464fn windows_screens() -> Result<Vec<ScreenInfo>> {
465    Err(FerrisError::new(
466        ErrorKind::Platform,
467        "native Windows capture is only available on Windows",
468    ))
469}
470
471#[cfg(target_os = "windows")]
472unsafe extern "system" fn collect_windows_monitor(
473    monitor: *mut std::ffi::c_void,
474    _dc: *mut std::ffi::c_void,
475    _rect: *mut WinRect,
476    context: isize,
477) -> i32 {
478    let mut info = MonitorInfoExW {
479        cb_size: std::mem::size_of::<MonitorInfoExW>() as u32,
480        rc_monitor: WinRect::default(),
481        rc_work: WinRect::default(),
482        flags: 0,
483        device: [0; 32],
484    };
485    if unsafe { GetMonitorInfoW(monitor, &mut info) } == 0 {
486        return 1;
487    }
488    let rect = info.rc_monitor;
489    let width = rect.right.saturating_sub(rect.left).max(1) as u32;
490    let height = rect.bottom.saturating_sub(rect.top).max(1) as u32;
491    let name_end = info
492        .device
493        .iter()
494        .position(|value| *value == 0)
495        .unwrap_or(info.device.len());
496    let name = String::from_utf16_lossy(&info.device[..name_end]);
497    let mut dpi_x = 96_u32;
498    let mut dpi_y = 96_u32;
499    let scale_factor = if unsafe { GetDpiForMonitor(monitor, 0, &mut dpi_x, &mut dpi_y) } == 0 {
500        dpi_x as f32 / 96.0
501    } else {
502        1.0
503    };
504    let screens = unsafe { &mut *(context as *mut Vec<ScreenInfo>) };
505    screens.push(ScreenInfo {
506        screen_id: String::new(),
507        name: if name.is_empty() {
508            "Windows Display".to_string()
509        } else {
510            name
511        },
512        is_primary: info.flags & MONITORINFOF_PRIMARY != 0,
513        origin_x: rect.left,
514        origin_y: rect.top,
515        native_width: width,
516        native_height: height,
517        scale_factor,
518    });
519    1
520}
521
522#[cfg(target_os = "windows")]
523fn set_windows_dpi_awareness() {
524    // This can legitimately fail when a host has already fixed the process DPI mode.
525    unsafe {
526        SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
527    }
528}
529
530#[cfg(target_os = "windows")]
531fn capture_windows_display(screen: &ScreenInfo, screenshot_path: &Path) -> Result<()> {
532    set_windows_dpi_awareness();
533    let width = screen.native_width.max(1);
534    let height = screen.native_height.max(1);
535    let desktop_dc = unsafe { GetDC(std::ptr::null_mut()) };
536    if desktop_dc.is_null() {
537        return Err(windows_capture_error("GetDC failed"));
538    }
539    let memory_dc = unsafe { CreateCompatibleDC(desktop_dc) };
540    if memory_dc.is_null() {
541        unsafe { ReleaseDC(std::ptr::null_mut(), desktop_dc) };
542        return Err(windows_capture_error("CreateCompatibleDC failed"));
543    }
544    let bitmap = unsafe { CreateCompatibleBitmap(desktop_dc, width as i32, height as i32) };
545    if bitmap.is_null() {
546        unsafe {
547            DeleteDC(memory_dc);
548            ReleaseDC(std::ptr::null_mut(), desktop_dc);
549        }
550        return Err(windows_capture_error("CreateCompatibleBitmap failed"));
551    }
552    let previous = unsafe { SelectObject(memory_dc, bitmap) };
553    let copied = unsafe {
554        BitBlt(
555            memory_dc,
556            0,
557            0,
558            width as i32,
559            height as i32,
560            desktop_dc,
561            screen.origin_x,
562            screen.origin_y,
563            SRCCOPY | CAPTUREBLT,
564        )
565    };
566    unsafe {
567        SelectObject(memory_dc, previous);
568    }
569    let mut pixels = vec![0_u8; width as usize * height as usize * 4];
570    let mut bitmap_info = BitmapInfo {
571        header: BitmapInfoHeader {
572            size: std::mem::size_of::<BitmapInfoHeader>() as u32,
573            width: width as i32,
574            height: -(height as i32),
575            planes: 1,
576            bit_count: 32,
577            compression: BI_RGB,
578            size_image: 0,
579            x_pixels_per_meter: 0,
580            y_pixels_per_meter: 0,
581            colors_used: 0,
582            colors_important: 0,
583        },
584        colors: [RgbQuad::default()],
585    };
586    let rows = if copied != 0 {
587        unsafe {
588            GetDIBits(
589                desktop_dc,
590                bitmap,
591                0,
592                height,
593                pixels.as_mut_ptr().cast(),
594                &mut bitmap_info,
595                DIB_RGB_COLORS,
596            )
597        }
598    } else {
599        0
600    };
601    unsafe {
602        DeleteObject(bitmap);
603        DeleteDC(memory_dc);
604        ReleaseDC(std::ptr::null_mut(), desktop_dc);
605    }
606    if copied == 0 || rows != height as i32 {
607        return Err(windows_capture_error(
608            "Windows screen capture failed; ensure FerrisGrid is running in an unlocked interactive desktop session",
609        ));
610    }
611    for pixel in pixels.chunks_exact_mut(4) {
612        pixel.swap(0, 2);
613        pixel[3] = 255;
614    }
615    let image = RgbaImage::from_raw(width, height, pixels).ok_or_else(|| {
616        FerrisError::new(
617            ErrorKind::Capture,
618            "Windows screen capture returned an invalid pixel buffer",
619        )
620    })?;
621    DynamicImage::ImageRgba8(image)
622        .save(screenshot_path)
623        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
624    Ok(())
625}
626
627#[cfg(target_os = "windows")]
628fn windows_capture_error(context: &str) -> FerrisError {
629    FerrisError::new(
630        ErrorKind::Capture,
631        format!("{context}: {}", std::io::Error::last_os_error()),
632    )
633}
634
635#[cfg(not(target_os = "linux"))]
636fn linux_screens() -> Result<Vec<ScreenInfo>> {
637    Err(FerrisError::new(
638        ErrorKind::Platform,
639        "native Linux X11 capture is only available on Linux",
640    ))
641}
642
643#[cfg(target_os = "linux")]
644fn capture_linux_root(path: &Path) -> Result<()> {
645    let display = std::env::var("DISPLAY").unwrap_or_default();
646    if display.is_empty() {
647        return Err(FerrisError::new(
648            ErrorKind::Capture,
649            "DISPLAY is not set; run FerrisGrid inside an X11 session such as Xvfb/noVNC",
650        ));
651    }
652    let status = Command::new("import")
653        .arg("-window")
654        .arg("root")
655        .arg(path)
656        .status()
657        .map_err(|error| {
658            FerrisError::new(
659                ErrorKind::Capture,
660                format!("failed to run ImageMagick import: {error}"),
661            )
662        })?;
663    if !status.success() {
664        return Err(FerrisError::new(
665            ErrorKind::Capture,
666            "ImageMagick import failed; ensure the X11 display is reachable and imagemagick is installed",
667        ));
668    }
669    Ok(())
670}
671
672#[cfg(target_os = "linux")]
673fn run_output(program: &str, args: &[&str]) -> Result<String> {
674    let output = Command::new(program).args(args).output().map_err(|error| {
675        FerrisError::new(
676            ErrorKind::Capture,
677            format!("failed to run {program}: {error}"),
678        )
679    })?;
680    if !output.status.success() {
681        return Err(FerrisError::new(
682            ErrorKind::Capture,
683            format!("{program} failed while querying the X11 display"),
684        ));
685    }
686    Ok(String::from_utf8_lossy(&output.stdout).to_string())
687}
688
689#[cfg(any(target_os = "linux", test))]
690fn parse_xrandr_screens(output: &str) -> Vec<ScreenInfo> {
691    let mut screens = output
692        .lines()
693        .filter(|line| line.contains(" connected"))
694        .filter_map(parse_xrandr_screen)
695        .collect::<Vec<_>>();
696    screens.sort_by_key(|screen| {
697        (
698            !screen.is_primary,
699            screen.origin_y,
700            screen.origin_x,
701            screen.name.clone(),
702        )
703    });
704    for (index, screen) in screens.iter_mut().enumerate() {
705        screen.screen_id = format!("screen-{}", index + 1);
706        screen.is_primary = index == 0 || screen.is_primary;
707    }
708    screens
709}
710
711#[cfg(any(target_os = "linux", test))]
712fn parse_xrandr_screen(line: &str) -> Option<ScreenInfo> {
713    let mut fields = line.split_whitespace();
714    let name = fields.next()?.to_string();
715    if fields.next()? != "connected" {
716        return None;
717    }
718    let is_primary = line.split_whitespace().any(|field| field == "primary");
719    let geometry = line
720        .split_whitespace()
721        .find_map(|field| parse_geometry(field))?;
722    Some(ScreenInfo {
723        screen_id: String::new(),
724        name,
725        is_primary,
726        origin_x: geometry.2,
727        origin_y: geometry.3,
728        native_width: geometry.0,
729        native_height: geometry.1,
730        scale_factor: 1.0,
731    })
732}
733
734#[cfg(any(target_os = "linux", test))]
735fn parse_geometry(value: &str) -> Option<(u32, u32, i32, i32)> {
736    let (width, rest) = value.split_once('x')?;
737    let x_start = rest.find(['+', '-'])?;
738    let height = &rest[..x_start];
739    let coords = &rest[x_start..];
740    let second_sign = coords[1..].find(['+', '-']).map(|index| index + 1)?;
741    let origin_x = &coords[..second_sign];
742    let origin_y = &coords[second_sign..];
743    Some((
744        width.parse().ok()?,
745        height.parse().ok()?,
746        origin_x.parse().ok()?,
747        origin_y.parse().ok()?,
748    ))
749}
750
751#[cfg(any(target_os = "linux", test))]
752fn parse_xdpyinfo_screens(output: &str) -> Vec<ScreenInfo> {
753    output
754        .lines()
755        .find_map(|line| {
756            let line = line.trim();
757            let rest = line.strip_prefix("dimensions:")?.trim();
758            let dimensions = rest.split_whitespace().next()?;
759            let (width, height) = dimensions.split_once('x')?;
760            Some(vec![ScreenInfo {
761                screen_id: "screen-1".to_string(),
762                name: "X11 Screen".to_string(),
763                is_primary: true,
764                origin_x: 0,
765                origin_y: 0,
766                native_width: width.parse().ok()?,
767                native_height: height.parse().ok()?,
768                scale_factor: 1.0,
769            }])
770        })
771        .unwrap_or_default()
772}
773
774#[cfg(target_os = "macos")]
775fn capture_macos_display(
776    display_index: usize,
777    screenshot_path: &Path,
778    format: &ImageFormat,
779) -> Result<()> {
780    let status = Command::new("/usr/sbin/screencapture")
781        .arg("-x")
782        .arg("-D")
783        .arg(display_index.to_string())
784        .arg("-t")
785        .arg(format.extension())
786        .arg(screenshot_path)
787        .status()
788        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
789    if !status.success() {
790        return Err(FerrisError::new(
791            ErrorKind::Capture,
792            "screencapture failed; check Screen Recording permission",
793        ));
794    }
795    Ok(())
796}
797
798fn fake_screens() -> Vec<ScreenInfo> {
799    vec![
800        ScreenInfo {
801            screen_id: "screen-1".to_string(),
802            name: "Fake Primary".to_string(),
803            is_primary: true,
804            origin_x: 0,
805            origin_y: 0,
806            native_width: 3024,
807            native_height: 1964,
808            scale_factor: 2.0,
809        },
810        ScreenInfo {
811            screen_id: "screen-2".to_string(),
812            name: "Fake Secondary".to_string(),
813            is_primary: false,
814            origin_x: 3024,
815            origin_y: 0,
816            native_width: 2560,
817            native_height: 1440,
818            scale_factor: 1.0,
819        },
820    ]
821}
822
823fn select_screens(screens: Vec<ScreenInfo>, target: CaptureTarget) -> Result<Vec<ScreenInfo>> {
824    match target {
825        CaptureTarget::All => Ok(screens),
826        CaptureTarget::Screen(id) => screens
827            .into_iter()
828            .filter(|screen| screen.screen_id == id)
829            .collect::<Vec<_>>()
830            .pipe(|selected| {
831                if selected.is_empty() {
832                    Err(FerrisError::new(
833                        ErrorKind::Coordinate,
834                        format!("unknown screen_id: {id}"),
835                    ))
836                } else {
837                    Ok(selected)
838                }
839            }),
840    }
841}
842
843#[allow(dead_code)]
844fn select_native_screens(
845    screens: Vec<NativeScreen>,
846    target: CaptureTarget,
847) -> Result<Vec<NativeScreen>> {
848    match target {
849        CaptureTarget::All => Ok(screens),
850        CaptureTarget::Screen(id) => screens
851            .into_iter()
852            .filter(|screen| screen.info.screen_id == id)
853            .collect::<Vec<_>>()
854            .pipe(|selected| {
855                if selected.is_empty() {
856                    Err(FerrisError::new(
857                        ErrorKind::Coordinate,
858                        format!("unknown screen_id: {id}"),
859                    ))
860                } else {
861                    Ok(selected)
862                }
863            }),
864    }
865}
866
867fn write_fake_captures(
868    screens: Vec<ScreenInfo>,
869    frame_dir: &Path,
870    format: &ImageFormat,
871    grid_overlay: bool,
872    image_size_limit: ImageSizeLimit,
873) -> Result<Vec<CapturedScreen>> {
874    fs::create_dir_all(frame_dir)?;
875    let mut captured = Vec::new();
876    for screen in screens {
877        let (image_width, image_height) =
878            scaled_dimensions(screen.native_width, screen.native_height, image_size_limit);
879        let screenshot_path =
880            frame_dir.join(format!("{}.{}", screen.screen_id, format.extension()));
881        write_placeholder_image(&screenshot_path, image_width, image_height)?;
882        if grid_overlay {
883            apply_grid_overlay(&screenshot_path)?;
884        }
885        let metadata_path = write_metadata(
886            frame_dir,
887            &screen,
888            &screenshot_path,
889            image_width,
890            image_height,
891        )?;
892        captured.push(CapturedScreen {
893            screen,
894            image_width,
895            image_height,
896            screenshot_path,
897            metadata_path,
898        });
899    }
900    Ok(captured)
901}
902
903fn write_metadata(
904    frame_dir: &Path,
905    screen: &ScreenInfo,
906    screenshot_path: &Path,
907    image_width: u32,
908    image_height: u32,
909) -> Result<PathBuf> {
910    let metadata_path = frame_dir.join(format!("{}.meta.md", screen.screen_id));
911    fs::write(
912        &metadata_path,
913        format!(
914            "## Screen Metadata\n- screen_id: {}\n- name: {}\n- coordinate_mode: normalized-1000\n- coordinate_range: x=0..1000 y=0..1000\n- coordinate_origin: top_left\n- coordinate_scope: screen_local\n- image_mapping: image_x=round(x/1000*(image_width-1)) image_y=round(y/1000*(image_height-1))\n- native_mapping: native_x=origin_x+round(x/1000*native_width) native_y=origin_y+round(y/1000*native_height)\n- origin_x: {}\n- origin_y: {}\n- native_width: {}\n- native_height: {}\n- image_width: {}\n- image_height: {}\n- scale_factor: {}\n- is_primary: {}\n- screenshot: {}\n",
915            screen.screen_id,
916            screen.name,
917            screen.origin_x,
918            screen.origin_y,
919            screen.native_width,
920            screen.native_height,
921            image_width,
922            image_height,
923            screen.scale_factor,
924            screen.is_primary,
925            screenshot_path.display()
926        ),
927    )?;
928    Ok(metadata_path)
929}
930
931fn write_placeholder_image(path: &Path, width: u32, height: u32) -> Result<()> {
932    let width = width.max(1);
933    let height = height.max(1);
934    let mut image = RgbaImage::new(width, height);
935    for (x, y, pixel) in image.enumerate_pixels_mut() {
936        let shade = ((x + y) % 255) as u8;
937        *pixel = Rgba([shade, 80, 180, 255]);
938    }
939    DynamicImage::ImageRgba8(image)
940        .save(path)
941        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
942    Ok(())
943}
944
945fn image_dimensions(path: &Path) -> Result<(u32, u32)> {
946    image::image_dimensions(path)
947        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))
948}
949
950fn downsample_image(path: &Path, image_size_limit: ImageSizeLimit) -> Result<()> {
951    let image = ImageReader::open(path)
952        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
953        .with_guessed_format()
954        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
955        .decode()
956        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
957    let (width, height) = (image.width(), image.height());
958    let Some(max_edge) = target_max_edge(width, height, image_size_limit) else {
959        return Ok(());
960    };
961    let longest = width.max(height);
962    if longest <= max_edge {
963        return Ok(());
964    }
965    let (new_width, new_height) =
966        scaled_dimensions(width, height, ImageSizeLimit::FixedMaxEdge(max_edge));
967    image
968        .resize(new_width, new_height, FilterType::Triangle)
969        .save(path)
970        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
971    Ok(())
972}
973
974fn scaled_dimensions(width: u32, height: u32, image_size_limit: ImageSizeLimit) -> (u32, u32) {
975    let width = width.max(1);
976    let height = height.max(1);
977    let Some(max_edge) = target_max_edge(width, height, image_size_limit) else {
978        return (width, height);
979    };
980    let longest = width.max(height);
981    if longest <= max_edge {
982        return (width, height);
983    }
984    let scale = max_edge as f64 / longest as f64;
985    (
986        ((width as f64 * scale).round() as u32).max(1),
987        ((height as f64 * scale).round() as u32).max(1),
988    )
989}
990
991fn target_max_edge(width: u32, height: u32, image_size_limit: ImageSizeLimit) -> Option<u32> {
992    let width = width.max(1);
993    let height = height.max(1);
994    match image_size_limit {
995        ImageSizeLimit::Native => None,
996        ImageSizeLimit::FixedMaxEdge(edge) => Some(edge.max(1)),
997        ImageSizeLimit::Adaptive {
998            min_long_edge,
999            min_short_edge,
1000        } => {
1001            let longest = width.max(height);
1002            let shortest = width.min(height);
1003            let short_side_cap =
1004                ((longest as u64 * min_short_edge.max(1) as u64).div_ceil(shortest as u64)) as u32;
1005            Some(longest.min(min_long_edge.max(short_side_cap).max(1)))
1006        }
1007    }
1008}
1009
1010fn apply_grid_overlay(path: &Path) -> Result<()> {
1011    let mut image = ImageReader::open(path)
1012        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
1013        .with_guessed_format()
1014        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
1015        .decode()
1016        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
1017        .to_rgba8();
1018    draw_grid(&mut image);
1019    DynamicImage::ImageRgba8(image)
1020        .save(path)
1021        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
1022    Ok(())
1023}
1024
1025fn draw_grid(image: &mut RgbaImage) {
1026    let (width, height) = image.dimensions();
1027    if width == 0 || height == 0 {
1028        return;
1029    }
1030
1031    let minor = Rgba([255, 210, 0, 155]);
1032    let major = Rgba([255, 90, 0, 210]);
1033    let axis = Rgba([0, 180, 255, 235]);
1034    let label = Rgba([255, 255, 255, 245]);
1035    let label_bg = Rgba([0, 0, 0, 190]);
1036
1037    for tick in (0..=1000).step_by(100) {
1038        let x = normalized_to_pixel(tick, width);
1039        let color = if tick == 0 || tick == 500 || tick == 1000 {
1040            major
1041        } else {
1042            minor
1043        };
1044        draw_vertical(image, x, color, 1);
1045
1046        let y = normalized_to_pixel(tick, height);
1047        draw_horizontal(image, y, color, 1);
1048    }
1049
1050    draw_vertical(image, normalized_to_pixel(500, width), axis, 2);
1051    draw_horizontal(image, normalized_to_pixel(500, height), axis, 2);
1052
1053    for tick in (0..=1000).step_by(100) {
1054        let x = normalized_to_pixel(tick, width);
1055        let y = normalized_to_pixel(tick, height);
1056        draw_square(image, x, normalized_to_pixel(500, height), axis, 3);
1057        draw_square(image, normalized_to_pixel(500, width), y, axis, 3);
1058    }
1059
1060    for tick in (0..=1000).step_by(100) {
1061        let x = normalized_to_pixel(tick, width);
1062        let y = normalized_to_pixel(tick, height);
1063        draw_centered_label(image, x, 8, &tick.to_string(), label, label_bg);
1064        draw_label(
1065            image,
1066            8,
1067            y.saturating_sub(8),
1068            &tick.to_string(),
1069            label,
1070            label_bg,
1071        );
1072    }
1073    draw_label(image, 8, 8, "0,0", axis, label_bg);
1074    let center_y = normalized_to_pixel(500, height).saturating_add(10);
1075    draw_centered_label(
1076        image,
1077        normalized_to_pixel(500, width),
1078        center_y,
1079        "500,500",
1080        axis,
1081        label_bg,
1082    );
1083    let bottom_y = height.saturating_sub(22);
1084    draw_centered_label(
1085        image,
1086        normalized_to_pixel(1000, width),
1087        bottom_y,
1088        "1000,1000",
1089        major,
1090        label_bg,
1091    );
1092}
1093
1094fn normalized_to_pixel(value: u32, size: u32) -> u32 {
1095    (((value as f64 / 1000.0) * (size.saturating_sub(1)) as f64).round() as u32)
1096        .min(size.saturating_sub(1))
1097}
1098
1099fn draw_vertical(image: &mut RgbaImage, x: u32, color: Rgba<u8>, radius: u32) {
1100    let (width, height) = image.dimensions();
1101    let start = x.saturating_sub(radius);
1102    let end = (x + radius).min(width.saturating_sub(1));
1103    for px in start..=end {
1104        for y in 0..height {
1105            blend_pixel(image, px, y, color);
1106        }
1107    }
1108}
1109
1110fn draw_horizontal(image: &mut RgbaImage, y: u32, color: Rgba<u8>, radius: u32) {
1111    let (width, height) = image.dimensions();
1112    let start = y.saturating_sub(radius);
1113    let end = (y + radius).min(height.saturating_sub(1));
1114    for py in start..=end {
1115        for x in 0..width {
1116            blend_pixel(image, x, py, color);
1117        }
1118    }
1119}
1120
1121fn draw_square(image: &mut RgbaImage, x: u32, y: u32, color: Rgba<u8>, radius: u32) {
1122    let (width, height) = image.dimensions();
1123    let x_start = x.saturating_sub(radius);
1124    let x_end = (x + radius).min(width.saturating_sub(1));
1125    let y_start = y.saturating_sub(radius);
1126    let y_end = (y + radius).min(height.saturating_sub(1));
1127    for px in x_start..=x_end {
1128        for py in y_start..=y_end {
1129            blend_pixel(image, px, py, color);
1130        }
1131    }
1132}
1133
1134fn draw_centered_label(
1135    image: &mut RgbaImage,
1136    center_x: u32,
1137    y: u32,
1138    text: &str,
1139    color: Rgba<u8>,
1140    background: Rgba<u8>,
1141) {
1142    let (image_width, _) = image.dimensions();
1143    let label_width = text_width(text).saturating_add(4);
1144    let x = center_x
1145        .saturating_sub(label_width / 2)
1146        .min(image_width.saturating_sub(label_width.saturating_add(1)));
1147    draw_label(image, x, y, text, color, background);
1148}
1149
1150fn draw_label(
1151    image: &mut RgbaImage,
1152    x: u32,
1153    y: u32,
1154    text: &str,
1155    color: Rgba<u8>,
1156    background: Rgba<u8>,
1157) {
1158    let (width, height) = image.dimensions();
1159    let text_width = text_width(text);
1160    let bg_width = text_width.saturating_add(4);
1161    let bg_height = 16;
1162    let x = x.min(width.saturating_sub(1));
1163    let y = y.min(height.saturating_sub(1));
1164    let x_end = x.saturating_add(bg_width).min(width.saturating_sub(1));
1165    let y_end = y.saturating_add(bg_height).min(height.saturating_sub(1));
1166    for px in x..=x_end {
1167        for py in y..=y_end {
1168            blend_pixel(image, px, py, background);
1169        }
1170    }
1171    let mut cursor = x.saturating_add(2);
1172    for ch in text.chars() {
1173        draw_char(image, cursor, y.saturating_add(3), ch, color);
1174        cursor = cursor.saturating_add(char_width(ch).saturating_add(2));
1175    }
1176}
1177
1178fn text_width(text: &str) -> u32 {
1179    text.chars()
1180        .map(|ch| char_width(ch).saturating_add(2))
1181        .sum::<u32>()
1182        .saturating_sub(2)
1183}
1184
1185fn char_width(ch: char) -> u32 {
1186    match ch {
1187        ',' | '.' => 2,
1188        '-' => 4,
1189        _ => 8,
1190    }
1191}
1192
1193fn draw_char(image: &mut RgbaImage, x: u32, y: u32, ch: char, color: Rgba<u8>) {
1194    let Some(pattern) = glyph(ch) else {
1195        return;
1196    };
1197    for (row, bits) in pattern.iter().enumerate() {
1198        for col in 0..5 {
1199            if (bits & (1 << (4 - col))) != 0 {
1200                let px = x.saturating_add((col * 2) as u32);
1201                let py = y.saturating_add((row * 2) as u32);
1202                draw_square(image, px, py, color, 1);
1203            }
1204        }
1205    }
1206}
1207
1208fn glyph(ch: char) -> Option<[u8; 5]> {
1209    match ch {
1210        '0' => Some([0b11111, 0b10001, 0b10001, 0b10001, 0b11111]),
1211        '1' => Some([0b00100, 0b01100, 0b00100, 0b00100, 0b01110]),
1212        '2' => Some([0b11111, 0b00001, 0b11111, 0b10000, 0b11111]),
1213        '3' => Some([0b11111, 0b00001, 0b11111, 0b00001, 0b11111]),
1214        '4' => Some([0b10001, 0b10001, 0b11111, 0b00001, 0b00001]),
1215        '5' => Some([0b11111, 0b10000, 0b11111, 0b00001, 0b11111]),
1216        '6' => Some([0b11111, 0b10000, 0b11111, 0b10001, 0b11111]),
1217        '7' => Some([0b11111, 0b00001, 0b00010, 0b00100, 0b00100]),
1218        '8' => Some([0b11111, 0b10001, 0b11111, 0b10001, 0b11111]),
1219        '9' => Some([0b11111, 0b10001, 0b11111, 0b00001, 0b11111]),
1220        ',' => Some([0b00, 0b00, 0b00, 0b10, 0b10]),
1221        '-' => Some([0b00000, 0b00000, 0b11110, 0b00000, 0b00000]),
1222        _ => None,
1223    }
1224}
1225
1226fn blend_pixel(image: &mut RgbaImage, x: u32, y: u32, overlay: Rgba<u8>) {
1227    let alpha = overlay[3] as f32 / 255.0;
1228    let pixel = image.get_pixel_mut(x, y);
1229    for channel in 0..3 {
1230        pixel[channel] = ((overlay[channel] as f32 * alpha)
1231            + (pixel[channel] as f32 * (1.0 - alpha)))
1232            .round() as u8;
1233    }
1234    pixel[3] = 255;
1235}
1236
1237#[cfg(target_os = "macos")]
1238#[repr(C)]
1239#[derive(Clone, Copy)]
1240struct CGPoint {
1241    x: f64,
1242    y: f64,
1243}
1244
1245#[cfg(target_os = "macos")]
1246#[repr(C)]
1247#[derive(Clone, Copy)]
1248struct CGSize {
1249    width: f64,
1250    height: f64,
1251}
1252
1253#[cfg(target_os = "macos")]
1254#[repr(C)]
1255#[derive(Clone, Copy)]
1256struct CGRect {
1257    origin: CGPoint,
1258    size: CGSize,
1259}
1260
1261#[cfg(target_os = "macos")]
1262#[link(name = "CoreGraphics", kind = "framework")]
1263unsafe extern "C" {
1264    fn CGGetActiveDisplayList(
1265        max_displays: u32,
1266        active_displays: *mut u32,
1267        display_count: *mut u32,
1268    ) -> i32;
1269    fn CGGetOnlineDisplayList(
1270        max_displays: u32,
1271        online_displays: *mut u32,
1272        display_count: *mut u32,
1273    ) -> i32;
1274    fn CGMainDisplayID() -> u32;
1275    fn CGDisplayBounds(display: u32) -> CGRect;
1276    fn CGDisplayPixelsWide(display: u32) -> usize;
1277    fn CGDisplayPixelsHigh(display: u32) -> usize;
1278}
1279
1280#[cfg(target_os = "windows")]
1281const MONITORINFOF_PRIMARY: u32 = 1;
1282#[cfg(target_os = "windows")]
1283const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2: isize = -4;
1284#[cfg(target_os = "windows")]
1285const SRCCOPY: u32 = 0x00cc_0020;
1286#[cfg(target_os = "windows")]
1287const CAPTUREBLT: u32 = 0x4000_0000;
1288#[cfg(target_os = "windows")]
1289const BI_RGB: u32 = 0;
1290#[cfg(target_os = "windows")]
1291const DIB_RGB_COLORS: u32 = 0;
1292
1293#[cfg(target_os = "windows")]
1294#[repr(C)]
1295#[derive(Clone, Copy, Default)]
1296struct WinRect {
1297    left: i32,
1298    top: i32,
1299    right: i32,
1300    bottom: i32,
1301}
1302
1303#[cfg(target_os = "windows")]
1304#[repr(C)]
1305struct MonitorInfoExW {
1306    cb_size: u32,
1307    rc_monitor: WinRect,
1308    rc_work: WinRect,
1309    flags: u32,
1310    device: [u16; 32],
1311}
1312
1313#[cfg(target_os = "windows")]
1314#[repr(C)]
1315struct BitmapInfoHeader {
1316    size: u32,
1317    width: i32,
1318    height: i32,
1319    planes: u16,
1320    bit_count: u16,
1321    compression: u32,
1322    size_image: u32,
1323    x_pixels_per_meter: i32,
1324    y_pixels_per_meter: i32,
1325    colors_used: u32,
1326    colors_important: u32,
1327}
1328
1329#[cfg(target_os = "windows")]
1330#[repr(C)]
1331#[derive(Clone, Copy, Default)]
1332struct RgbQuad {
1333    blue: u8,
1334    green: u8,
1335    red: u8,
1336    reserved: u8,
1337}
1338
1339#[cfg(target_os = "windows")]
1340#[repr(C)]
1341struct BitmapInfo {
1342    header: BitmapInfoHeader,
1343    colors: [RgbQuad; 1],
1344}
1345
1346#[cfg(target_os = "windows")]
1347#[link(name = "user32")]
1348unsafe extern "system" {
1349    fn EnumDisplayMonitors(
1350        dc: *mut std::ffi::c_void,
1351        clip: *const WinRect,
1352        callback: Option<
1353            unsafe extern "system" fn(
1354                *mut std::ffi::c_void,
1355                *mut std::ffi::c_void,
1356                *mut WinRect,
1357                isize,
1358            ) -> i32,
1359        >,
1360        context: isize,
1361    ) -> i32;
1362    fn GetMonitorInfoW(monitor: *mut std::ffi::c_void, info: *mut MonitorInfoExW) -> i32;
1363    fn SetProcessDpiAwarenessContext(context: isize) -> i32;
1364    fn GetDC(window: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
1365    fn ReleaseDC(window: *mut std::ffi::c_void, dc: *mut std::ffi::c_void) -> i32;
1366}
1367
1368#[cfg(target_os = "windows")]
1369#[link(name = "shcore")]
1370unsafe extern "system" {
1371    fn GetDpiForMonitor(
1372        monitor: *mut std::ffi::c_void,
1373        dpi_type: i32,
1374        dpi_x: *mut u32,
1375        dpi_y: *mut u32,
1376    ) -> i32;
1377}
1378
1379#[cfg(target_os = "windows")]
1380#[link(name = "gdi32")]
1381unsafe extern "system" {
1382    fn CreateCompatibleDC(dc: *mut std::ffi::c_void) -> *mut std::ffi::c_void;
1383    fn CreateCompatibleBitmap(
1384        dc: *mut std::ffi::c_void,
1385        width: i32,
1386        height: i32,
1387    ) -> *mut std::ffi::c_void;
1388    fn SelectObject(
1389        dc: *mut std::ffi::c_void,
1390        object: *mut std::ffi::c_void,
1391    ) -> *mut std::ffi::c_void;
1392    fn BitBlt(
1393        destination: *mut std::ffi::c_void,
1394        x: i32,
1395        y: i32,
1396        width: i32,
1397        height: i32,
1398        source: *mut std::ffi::c_void,
1399        source_x: i32,
1400        source_y: i32,
1401        operation: u32,
1402    ) -> i32;
1403    fn GetDIBits(
1404        dc: *mut std::ffi::c_void,
1405        bitmap: *mut std::ffi::c_void,
1406        start: u32,
1407        lines: u32,
1408        bits: *mut std::ffi::c_void,
1409        info: *mut BitmapInfo,
1410        usage: u32,
1411    ) -> i32;
1412    fn DeleteObject(object: *mut std::ffi::c_void) -> i32;
1413    fn DeleteDC(dc: *mut std::ffi::c_void) -> i32;
1414}
1415
1416trait Pipe: Sized {
1417    fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
1418        f(self)
1419    }
1420}
1421
1422impl<T> Pipe for T {}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427
1428    #[test]
1429    fn parses_xrandr_screen_topology() {
1430        let screens = parse_xrandr_screens(
1431            "Screen 0: minimum 8 x 8, current 3200 x 1080, maximum 32767 x 32767\n\
1432             HDMI-1 connected 1920x1080+1280+0 normal left inverted right x axis y axis\n\
1433             eDP-1 connected primary 1280x800+0+0 normal left inverted right x axis y axis\n",
1434        );
1435
1436        assert_eq!(screens.len(), 2);
1437        assert_eq!(screens[0].screen_id, "screen-1");
1438        assert_eq!(screens[0].name, "eDP-1");
1439        assert!(screens[0].is_primary);
1440        assert_eq!(screens[0].native_width, 1280);
1441        assert_eq!(screens[0].native_height, 800);
1442        assert_eq!(screens[1].screen_id, "screen-2");
1443        assert_eq!(screens[1].origin_x, 1280);
1444    }
1445
1446    #[test]
1447    fn parses_xvfb_xrandr_default_screen() {
1448        let screens = parse_xrandr_screens(
1449            "Screen 0: minimum 1 x 1, current 1280 x 800, maximum 8192 x 8192\n\
1450             screen connected primary 1280x800+0+0 0mm x 0mm\n",
1451        );
1452
1453        assert_eq!(screens.len(), 1);
1454        assert_eq!(screens[0].screen_id, "screen-1");
1455        assert_eq!(screens[0].native_width, 1280);
1456        assert_eq!(screens[0].native_height, 800);
1457    }
1458
1459    #[test]
1460    fn parses_xdpyinfo_dimensions_fallback() {
1461        let screens = parse_xdpyinfo_screens(
1462            "name of display:    :99\n\
1463             screen #0:\n\
1464               dimensions:    1440x900 pixels (381x238 millimeters)\n",
1465        );
1466
1467        assert_eq!(screens.len(), 1);
1468        assert_eq!(screens[0].screen_id, "screen-1");
1469        assert_eq!(screens[0].native_width, 1440);
1470        assert_eq!(screens[0].native_height, 900);
1471    }
1472
1473    #[test]
1474    fn adaptive_limit_preserves_minimum_short_edge() {
1475        let limit = ImageSizeLimit::Adaptive {
1476            min_long_edge: 800,
1477            min_short_edge: 500,
1478        };
1479
1480        assert_eq!(scaled_dimensions(1710, 1107, limit), (800, 518));
1481        assert_eq!(scaled_dimensions(2560, 1440, limit), (889, 500));
1482        assert_eq!(scaled_dimensions(3440, 1440, limit), (1195, 500));
1483        assert_eq!(scaled_dimensions(5120, 1440, limit), (1778, 500));
1484    }
1485
1486    #[test]
1487    fn adaptive_limit_never_upscales_small_screens() {
1488        let limit = ImageSizeLimit::Adaptive {
1489            min_long_edge: 800,
1490            min_short_edge: 500,
1491        };
1492
1493        assert_eq!(scaled_dimensions(640, 400, limit), (640, 400));
1494    }
1495
1496    #[test]
1497    fn windows_backend_aliases_are_explicit() {
1498        assert_eq!(backend_by_name("windows").name(), "native-windows");
1499        assert_eq!(backend_by_name("win32").name(), "native-windows");
1500        assert_eq!(backend_by_name("native-windows").name(), "native-windows");
1501    }
1502
1503    #[test]
1504    fn windows_screen_order_is_primary_first_and_preserves_negative_origins() {
1505        let screens = normalize_screen_order(vec![
1506            ScreenInfo {
1507                screen_id: String::new(),
1508                name: "Left".to_string(),
1509                is_primary: false,
1510                origin_x: -1920,
1511                origin_y: 0,
1512                native_width: 1920,
1513                native_height: 1080,
1514                scale_factor: 1.0,
1515            },
1516            ScreenInfo {
1517                screen_id: String::new(),
1518                name: "Primary".to_string(),
1519                is_primary: true,
1520                origin_x: 0,
1521                origin_y: 0,
1522                native_width: 2560,
1523                native_height: 1440,
1524                scale_factor: 1.5,
1525            },
1526        ]);
1527
1528        assert_eq!(screens[0].screen_id, "screen-1");
1529        assert_eq!(screens[0].name, "Primary");
1530        assert_eq!(screens[1].screen_id, "screen-2");
1531        assert_eq!(screens[1].origin_x, -1920);
1532    }
1533}