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};
8use std::process::Command;
9
10pub struct FakeCaptureBackend;
11
12impl FakeCaptureBackend {
13    pub fn new() -> Self {
14        Self
15    }
16}
17
18impl Default for FakeCaptureBackend {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl CaptureBackend for FakeCaptureBackend {
25    fn name(&self) -> &'static str {
26        "fake"
27    }
28
29    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
30        Ok(fake_screens())
31    }
32
33    fn capture(
34        &self,
35        target: CaptureTarget,
36        frame_dir: &Path,
37        format: &ImageFormat,
38        grid_overlay: bool,
39        image_size_limit: ImageSizeLimit,
40    ) -> Result<Vec<CapturedScreen>> {
41        let screens = select_screens(fake_screens(), target)?;
42        write_fake_captures(screens, frame_dir, format, grid_overlay, image_size_limit)
43    }
44}
45
46pub struct MacOsCaptureBackend;
47
48impl CaptureBackend for MacOsCaptureBackend {
49    fn name(&self) -> &'static str {
50        "native-macos"
51    }
52
53    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
54        Ok(native_screens()?
55            .into_iter()
56            .map(|screen| screen.info)
57            .collect())
58    }
59
60    fn capture(
61        &self,
62        target: CaptureTarget,
63        frame_dir: &Path,
64        format: &ImageFormat,
65        grid_overlay: bool,
66        image_size_limit: ImageSizeLimit,
67    ) -> Result<Vec<CapturedScreen>> {
68        #[cfg(target_os = "macos")]
69        {
70            let screens = select_native_screens(native_screens()?, target)?;
71            fs::create_dir_all(frame_dir)?;
72            let mut captured = Vec::new();
73            for screen in screens {
74                let screenshot_path =
75                    frame_dir.join(format!("{}.{}", screen.info.screen_id, format.extension()));
76                capture_macos_display(screen.capture_display_index, &screenshot_path, format)?;
77                downsample_image(&screenshot_path, image_size_limit)?;
78                if grid_overlay {
79                    apply_grid_overlay(&screenshot_path)?;
80                }
81                let (image_width, image_height) = image_dimensions(&screenshot_path)
82                    .unwrap_or((screen.info.native_width, screen.info.native_height));
83                let metadata_path = write_metadata(
84                    frame_dir,
85                    &screen.info,
86                    &screenshot_path,
87                    image_width,
88                    image_height,
89                )?;
90                captured.push(CapturedScreen {
91                    image_width,
92                    image_height,
93                    screen: screen.info,
94                    screenshot_path,
95                    metadata_path,
96                });
97            }
98            Ok(captured)
99        }
100        #[cfg(not(target_os = "macos"))]
101        {
102            let _ = (target, frame_dir, format, grid_overlay, image_size_limit);
103            Err(FerrisError::new(
104                ErrorKind::Platform,
105                "native backend is currently implemented for macOS only; use --backend fake for local protocol tests",
106            ))
107        }
108    }
109}
110
111pub struct LinuxCaptureBackend;
112
113impl CaptureBackend for LinuxCaptureBackend {
114    fn name(&self) -> &'static str {
115        "native-linux-x11"
116    }
117
118    fn list_screens(&self) -> Result<Vec<ScreenInfo>> {
119        linux_screens()
120    }
121
122    fn capture(
123        &self,
124        target: CaptureTarget,
125        frame_dir: &Path,
126        format: &ImageFormat,
127        grid_overlay: bool,
128        image_size_limit: ImageSizeLimit,
129    ) -> Result<Vec<CapturedScreen>> {
130        #[cfg(target_os = "linux")]
131        {
132            let screens = select_screens(linux_screens()?, target)?;
133            fs::create_dir_all(frame_dir)?;
134
135            let root_path = frame_dir.join("root-capture.png");
136            capture_linux_root(&root_path)?;
137            let root_image = ImageReader::open(&root_path)
138                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
139                .with_guessed_format()
140                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
141                .decode()
142                .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
143
144            let mut captured = Vec::new();
145            for screen in screens {
146                let x = screen.origin_x.max(0) as u32;
147                let y = screen.origin_y.max(0) as u32;
148                if x >= root_image.width() || y >= root_image.height() {
149                    return Err(FerrisError::new(
150                        ErrorKind::Capture,
151                        format!(
152                            "screen {} origin {},{} is outside root image {}x{}",
153                            screen.screen_id,
154                            screen.origin_x,
155                            screen.origin_y,
156                            root_image.width(),
157                            root_image.height()
158                        ),
159                    ));
160                }
161                let crop_width = screen
162                    .native_width
163                    .min(root_image.width().saturating_sub(x))
164                    .max(1);
165                let crop_height = screen
166                    .native_height
167                    .min(root_image.height().saturating_sub(y))
168                    .max(1);
169                let screenshot_path =
170                    frame_dir.join(format!("{}.{}", screen.screen_id, format.extension()));
171                let cropped = root_image.crop_imm(x, y, crop_width, crop_height);
172                cropped
173                    .save(&screenshot_path)
174                    .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
175                downsample_image(&screenshot_path, image_size_limit)?;
176                if grid_overlay {
177                    apply_grid_overlay(&screenshot_path)?;
178                }
179                let (image_width, image_height) = image_dimensions(&screenshot_path)
180                    .unwrap_or((screen.native_width, screen.native_height));
181                let metadata_path = write_metadata(
182                    frame_dir,
183                    &screen,
184                    &screenshot_path,
185                    image_width,
186                    image_height,
187                )?;
188                captured.push(CapturedScreen {
189                    screen,
190                    image_width,
191                    image_height,
192                    screenshot_path,
193                    metadata_path,
194                });
195            }
196            let _ = fs::remove_file(root_path);
197            Ok(captured)
198        }
199        #[cfg(not(target_os = "linux"))]
200        {
201            let _ = (target, frame_dir, format, grid_overlay, image_size_limit);
202            Err(FerrisError::new(
203                ErrorKind::Platform,
204                "native Linux X11 capture is only available on Linux; use --backend native on this OS or --backend fake",
205            ))
206        }
207    }
208}
209
210pub fn backend_by_name(name: &str) -> Box<dyn CaptureBackend> {
211    match name {
212        "fake" => Box::new(FakeCaptureBackend),
213        "native" => native_backend(),
214        "macos" | "native-macos" => Box::new(MacOsCaptureBackend),
215        "linux" | "x11" | "native-linux" | "native-linux-x11" => Box::new(LinuxCaptureBackend),
216        _ => native_backend(),
217    }
218}
219
220fn native_backend() -> Box<dyn CaptureBackend> {
221    #[cfg(target_os = "linux")]
222    {
223        Box::new(LinuxCaptureBackend)
224    }
225    #[cfg(target_os = "macos")]
226    {
227        Box::new(MacOsCaptureBackend)
228    }
229    #[cfg(not(any(target_os = "linux", target_os = "macos")))]
230    {
231        Box::new(MacOsCaptureBackend)
232    }
233}
234
235#[derive(Clone)]
236#[allow(dead_code)]
237struct NativeScreen {
238    info: ScreenInfo,
239    capture_display_index: usize,
240}
241
242#[cfg(target_os = "macos")]
243fn native_screens() -> Result<Vec<NativeScreen>> {
244    let display_ids = display_ids()?;
245    if display_ids.is_empty() {
246        return Err(FerrisError::new(
247            ErrorKind::Capture,
248            "CoreGraphics returned no displays; run FerrisGrid from a logged-in desktop session with screen access",
249        ));
250    }
251
252    let main_display = unsafe { CGMainDisplayID() };
253    let mut screens = display_ids
254        .iter()
255        .enumerate()
256        .map(|(index, display_id)| {
257            let bounds = unsafe { CGDisplayBounds(*display_id) };
258            let native_width = unsafe { CGDisplayPixelsWide(*display_id) as u32 };
259            let native_height = unsafe { CGDisplayPixelsHigh(*display_id) as u32 };
260            let scale_factor = if bounds.size.width > 0.0 {
261                native_width as f64 / bounds.size.width
262            } else {
263                1.0
264            } as f32;
265            NativeScreen {
266                info: ScreenInfo {
267                    screen_id: String::new(),
268                    name: if *display_id == main_display {
269                        "Main Display".to_string()
270                    } else {
271                        format!("Display {}", index + 1)
272                    },
273                    is_primary: *display_id == main_display,
274                    origin_x: bounds.origin.x.round() as i32,
275                    origin_y: bounds.origin.y.round() as i32,
276                    native_width,
277                    native_height,
278                    scale_factor,
279                },
280                // screencapture uses 1 for the main display and subsequent display numbers
281                // for additional active displays.
282                capture_display_index: index + 1,
283            }
284        })
285        .collect::<Vec<_>>();
286
287    screens.sort_by_key(|screen| {
288        (
289            !screen.info.is_primary,
290            screen.info.origin_y,
291            screen.info.origin_x,
292        )
293    });
294    for (index, screen) in screens.iter_mut().enumerate() {
295        screen.info.screen_id = format!("screen-{}", index + 1);
296        screen.capture_display_index = index + 1;
297        if !screen.info.is_primary {
298            screen.info.name = format!("Display {}", index + 1);
299        }
300    }
301
302    Ok(screens)
303}
304
305#[cfg(target_os = "macos")]
306fn display_ids() -> Result<Vec<u32>> {
307    let mut ids = [0_u32; 32];
308    let mut count = 0_u32;
309    let active_error =
310        unsafe { CGGetActiveDisplayList(ids.len() as u32, ids.as_mut_ptr(), &mut count) };
311    if active_error == 0 && count > 0 {
312        return Ok(ids[..count as usize].to_vec());
313    }
314
315    count = 0;
316    let online_error =
317        unsafe { CGGetOnlineDisplayList(ids.len() as u32, ids.as_mut_ptr(), &mut count) };
318    if online_error != 0 {
319        return Err(FerrisError::new(
320            ErrorKind::Capture,
321            format!(
322                "CoreGraphics display discovery failed: active={active_error} online={online_error}"
323            ),
324        ));
325    }
326    Ok(ids[..count as usize].to_vec())
327}
328
329#[cfg(not(target_os = "macos"))]
330fn native_screens() -> Result<Vec<NativeScreen>> {
331    Err(FerrisError::new(
332        ErrorKind::Platform,
333        "native backend is currently implemented for macOS only; use --backend fake for local protocol tests",
334    ))
335}
336
337#[cfg(target_os = "linux")]
338fn linux_screens() -> Result<Vec<ScreenInfo>> {
339    let mut screens = run_output("xrandr", &["--query"])
340        .map(|output| parse_xrandr_screens(&output))
341        .unwrap_or_default();
342    if screens.is_empty() {
343        screens = run_output("xdpyinfo", &[])
344            .map(|output| parse_xdpyinfo_screens(&output))
345            .unwrap_or_default();
346    }
347    if screens.is_empty() {
348        return Err(FerrisError::new(
349            ErrorKind::Capture,
350            "could not discover an X11 screen; ensure DISPLAY is set and xrandr or xdpyinfo is installed",
351        ));
352    }
353    Ok(screens)
354}
355
356#[cfg(not(target_os = "linux"))]
357fn linux_screens() -> Result<Vec<ScreenInfo>> {
358    Err(FerrisError::new(
359        ErrorKind::Platform,
360        "native Linux X11 capture is only available on Linux",
361    ))
362}
363
364#[cfg(target_os = "linux")]
365fn capture_linux_root(path: &Path) -> Result<()> {
366    let display = std::env::var("DISPLAY").unwrap_or_default();
367    if display.is_empty() {
368        return Err(FerrisError::new(
369            ErrorKind::Capture,
370            "DISPLAY is not set; run FerrisGrid inside an X11 session such as Xvfb/noVNC",
371        ));
372    }
373    let status = Command::new("import")
374        .arg("-window")
375        .arg("root")
376        .arg(path)
377        .status()
378        .map_err(|error| {
379            FerrisError::new(
380                ErrorKind::Capture,
381                format!("failed to run ImageMagick import: {error}"),
382            )
383        })?;
384    if !status.success() {
385        return Err(FerrisError::new(
386            ErrorKind::Capture,
387            "ImageMagick import failed; ensure the X11 display is reachable and imagemagick is installed",
388        ));
389    }
390    Ok(())
391}
392
393#[cfg(target_os = "linux")]
394fn run_output(program: &str, args: &[&str]) -> Result<String> {
395    let output = Command::new(program).args(args).output().map_err(|error| {
396        FerrisError::new(
397            ErrorKind::Capture,
398            format!("failed to run {program}: {error}"),
399        )
400    })?;
401    if !output.status.success() {
402        return Err(FerrisError::new(
403            ErrorKind::Capture,
404            format!("{program} failed while querying the X11 display"),
405        ));
406    }
407    Ok(String::from_utf8_lossy(&output.stdout).to_string())
408}
409
410#[cfg(any(target_os = "linux", test))]
411fn parse_xrandr_screens(output: &str) -> Vec<ScreenInfo> {
412    let mut screens = output
413        .lines()
414        .filter(|line| line.contains(" connected"))
415        .filter_map(parse_xrandr_screen)
416        .collect::<Vec<_>>();
417    screens.sort_by_key(|screen| {
418        (
419            !screen.is_primary,
420            screen.origin_y,
421            screen.origin_x,
422            screen.name.clone(),
423        )
424    });
425    for (index, screen) in screens.iter_mut().enumerate() {
426        screen.screen_id = format!("screen-{}", index + 1);
427        screen.is_primary = index == 0 || screen.is_primary;
428    }
429    screens
430}
431
432#[cfg(any(target_os = "linux", test))]
433fn parse_xrandr_screen(line: &str) -> Option<ScreenInfo> {
434    let mut fields = line.split_whitespace();
435    let name = fields.next()?.to_string();
436    if fields.next()? != "connected" {
437        return None;
438    }
439    let is_primary = line.split_whitespace().any(|field| field == "primary");
440    let geometry = line
441        .split_whitespace()
442        .find_map(|field| parse_geometry(field))?;
443    Some(ScreenInfo {
444        screen_id: String::new(),
445        name,
446        is_primary,
447        origin_x: geometry.2,
448        origin_y: geometry.3,
449        native_width: geometry.0,
450        native_height: geometry.1,
451        scale_factor: 1.0,
452    })
453}
454
455#[cfg(any(target_os = "linux", test))]
456fn parse_geometry(value: &str) -> Option<(u32, u32, i32, i32)> {
457    let (width, rest) = value.split_once('x')?;
458    let x_start = rest.find(['+', '-'])?;
459    let height = &rest[..x_start];
460    let coords = &rest[x_start..];
461    let second_sign = coords[1..].find(['+', '-']).map(|index| index + 1)?;
462    let origin_x = &coords[..second_sign];
463    let origin_y = &coords[second_sign..];
464    Some((
465        width.parse().ok()?,
466        height.parse().ok()?,
467        origin_x.parse().ok()?,
468        origin_y.parse().ok()?,
469    ))
470}
471
472#[cfg(any(target_os = "linux", test))]
473fn parse_xdpyinfo_screens(output: &str) -> Vec<ScreenInfo> {
474    output
475        .lines()
476        .find_map(|line| {
477            let line = line.trim();
478            let rest = line.strip_prefix("dimensions:")?.trim();
479            let dimensions = rest.split_whitespace().next()?;
480            let (width, height) = dimensions.split_once('x')?;
481            Some(vec![ScreenInfo {
482                screen_id: "screen-1".to_string(),
483                name: "X11 Screen".to_string(),
484                is_primary: true,
485                origin_x: 0,
486                origin_y: 0,
487                native_width: width.parse().ok()?,
488                native_height: height.parse().ok()?,
489                scale_factor: 1.0,
490            }])
491        })
492        .unwrap_or_default()
493}
494
495#[cfg(target_os = "macos")]
496fn capture_macos_display(
497    display_index: usize,
498    screenshot_path: &Path,
499    format: &ImageFormat,
500) -> Result<()> {
501    let status = Command::new("/usr/sbin/screencapture")
502        .arg("-x")
503        .arg("-D")
504        .arg(display_index.to_string())
505        .arg("-t")
506        .arg(format.extension())
507        .arg(screenshot_path)
508        .status()
509        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
510    if !status.success() {
511        return Err(FerrisError::new(
512            ErrorKind::Capture,
513            "screencapture failed; check Screen Recording permission",
514        ));
515    }
516    Ok(())
517}
518
519fn fake_screens() -> Vec<ScreenInfo> {
520    vec![
521        ScreenInfo {
522            screen_id: "screen-1".to_string(),
523            name: "Fake Primary".to_string(),
524            is_primary: true,
525            origin_x: 0,
526            origin_y: 0,
527            native_width: 3024,
528            native_height: 1964,
529            scale_factor: 2.0,
530        },
531        ScreenInfo {
532            screen_id: "screen-2".to_string(),
533            name: "Fake Secondary".to_string(),
534            is_primary: false,
535            origin_x: 3024,
536            origin_y: 0,
537            native_width: 2560,
538            native_height: 1440,
539            scale_factor: 1.0,
540        },
541    ]
542}
543
544fn select_screens(screens: Vec<ScreenInfo>, target: CaptureTarget) -> Result<Vec<ScreenInfo>> {
545    match target {
546        CaptureTarget::All => Ok(screens),
547        CaptureTarget::Screen(id) => screens
548            .into_iter()
549            .filter(|screen| screen.screen_id == id)
550            .collect::<Vec<_>>()
551            .pipe(|selected| {
552                if selected.is_empty() {
553                    Err(FerrisError::new(
554                        ErrorKind::Coordinate,
555                        format!("unknown screen_id: {id}"),
556                    ))
557                } else {
558                    Ok(selected)
559                }
560            }),
561    }
562}
563
564#[allow(dead_code)]
565fn select_native_screens(
566    screens: Vec<NativeScreen>,
567    target: CaptureTarget,
568) -> Result<Vec<NativeScreen>> {
569    match target {
570        CaptureTarget::All => Ok(screens),
571        CaptureTarget::Screen(id) => screens
572            .into_iter()
573            .filter(|screen| screen.info.screen_id == id)
574            .collect::<Vec<_>>()
575            .pipe(|selected| {
576                if selected.is_empty() {
577                    Err(FerrisError::new(
578                        ErrorKind::Coordinate,
579                        format!("unknown screen_id: {id}"),
580                    ))
581                } else {
582                    Ok(selected)
583                }
584            }),
585    }
586}
587
588fn write_fake_captures(
589    screens: Vec<ScreenInfo>,
590    frame_dir: &Path,
591    format: &ImageFormat,
592    grid_overlay: bool,
593    image_size_limit: ImageSizeLimit,
594) -> Result<Vec<CapturedScreen>> {
595    fs::create_dir_all(frame_dir)?;
596    let mut captured = Vec::new();
597    for screen in screens {
598        let (image_width, image_height) =
599            scaled_dimensions(screen.native_width, screen.native_height, image_size_limit);
600        let screenshot_path =
601            frame_dir.join(format!("{}.{}", screen.screen_id, format.extension()));
602        write_placeholder_image(&screenshot_path, image_width, image_height)?;
603        if grid_overlay {
604            apply_grid_overlay(&screenshot_path)?;
605        }
606        let metadata_path = write_metadata(
607            frame_dir,
608            &screen,
609            &screenshot_path,
610            image_width,
611            image_height,
612        )?;
613        captured.push(CapturedScreen {
614            screen,
615            image_width,
616            image_height,
617            screenshot_path,
618            metadata_path,
619        });
620    }
621    Ok(captured)
622}
623
624fn write_metadata(
625    frame_dir: &Path,
626    screen: &ScreenInfo,
627    screenshot_path: &Path,
628    image_width: u32,
629    image_height: u32,
630) -> Result<PathBuf> {
631    let metadata_path = frame_dir.join(format!("{}.meta.md", screen.screen_id));
632    fs::write(
633        &metadata_path,
634        format!(
635            "## 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",
636            screen.screen_id,
637            screen.name,
638            screen.origin_x,
639            screen.origin_y,
640            screen.native_width,
641            screen.native_height,
642            image_width,
643            image_height,
644            screen.scale_factor,
645            screen.is_primary,
646            screenshot_path.display()
647        ),
648    )?;
649    Ok(metadata_path)
650}
651
652fn write_placeholder_image(path: &Path, width: u32, height: u32) -> Result<()> {
653    let width = width.max(1);
654    let height = height.max(1);
655    let mut image = RgbaImage::new(width, height);
656    for (x, y, pixel) in image.enumerate_pixels_mut() {
657        let shade = ((x + y) % 255) as u8;
658        *pixel = Rgba([shade, 80, 180, 255]);
659    }
660    DynamicImage::ImageRgba8(image)
661        .save(path)
662        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
663    Ok(())
664}
665
666fn image_dimensions(path: &Path) -> Result<(u32, u32)> {
667    image::image_dimensions(path)
668        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))
669}
670
671fn downsample_image(path: &Path, image_size_limit: ImageSizeLimit) -> Result<()> {
672    let image = ImageReader::open(path)
673        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
674        .with_guessed_format()
675        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
676        .decode()
677        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
678    let (width, height) = (image.width(), image.height());
679    let Some(max_edge) = target_max_edge(width, height, image_size_limit) else {
680        return Ok(());
681    };
682    let longest = width.max(height);
683    if longest <= max_edge {
684        return Ok(());
685    }
686    let (new_width, new_height) =
687        scaled_dimensions(width, height, ImageSizeLimit::FixedMaxEdge(max_edge));
688    image
689        .resize(new_width, new_height, FilterType::Triangle)
690        .save(path)
691        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
692    Ok(())
693}
694
695fn scaled_dimensions(width: u32, height: u32, image_size_limit: ImageSizeLimit) -> (u32, u32) {
696    let width = width.max(1);
697    let height = height.max(1);
698    let Some(max_edge) = target_max_edge(width, height, image_size_limit) else {
699        return (width, height);
700    };
701    let longest = width.max(height);
702    if longest <= max_edge {
703        return (width, height);
704    }
705    let scale = max_edge as f64 / longest as f64;
706    (
707        ((width as f64 * scale).round() as u32).max(1),
708        ((height as f64 * scale).round() as u32).max(1),
709    )
710}
711
712fn target_max_edge(width: u32, height: u32, image_size_limit: ImageSizeLimit) -> Option<u32> {
713    let width = width.max(1);
714    let height = height.max(1);
715    match image_size_limit {
716        ImageSizeLimit::Native => None,
717        ImageSizeLimit::FixedMaxEdge(edge) => Some(edge.max(1)),
718        ImageSizeLimit::Adaptive {
719            min_long_edge,
720            min_short_edge,
721        } => {
722            let longest = width.max(height);
723            let shortest = width.min(height);
724            let short_side_cap =
725                ((longest as u64 * min_short_edge.max(1) as u64).div_ceil(shortest as u64)) as u32;
726            Some(longest.min(min_long_edge.max(short_side_cap).max(1)))
727        }
728    }
729}
730
731fn apply_grid_overlay(path: &Path) -> Result<()> {
732    let mut image = ImageReader::open(path)
733        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
734        .with_guessed_format()
735        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
736        .decode()
737        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?
738        .to_rgba8();
739    draw_grid(&mut image);
740    DynamicImage::ImageRgba8(image)
741        .save(path)
742        .map_err(|error| FerrisError::new(ErrorKind::Capture, error.to_string()))?;
743    Ok(())
744}
745
746fn draw_grid(image: &mut RgbaImage) {
747    let (width, height) = image.dimensions();
748    if width == 0 || height == 0 {
749        return;
750    }
751
752    let minor = Rgba([255, 210, 0, 155]);
753    let major = Rgba([255, 90, 0, 210]);
754    let axis = Rgba([0, 180, 255, 235]);
755    let label = Rgba([255, 255, 255, 245]);
756    let label_bg = Rgba([0, 0, 0, 190]);
757
758    for tick in (0..=1000).step_by(100) {
759        let x = normalized_to_pixel(tick, width);
760        let color = if tick == 0 || tick == 500 || tick == 1000 {
761            major
762        } else {
763            minor
764        };
765        draw_vertical(image, x, color, 1);
766
767        let y = normalized_to_pixel(tick, height);
768        draw_horizontal(image, y, color, 1);
769    }
770
771    draw_vertical(image, normalized_to_pixel(500, width), axis, 2);
772    draw_horizontal(image, normalized_to_pixel(500, height), axis, 2);
773
774    for tick in (0..=1000).step_by(100) {
775        let x = normalized_to_pixel(tick, width);
776        let y = normalized_to_pixel(tick, height);
777        draw_square(image, x, normalized_to_pixel(500, height), axis, 3);
778        draw_square(image, normalized_to_pixel(500, width), y, axis, 3);
779    }
780
781    for tick in (0..=1000).step_by(100) {
782        let x = normalized_to_pixel(tick, width);
783        let y = normalized_to_pixel(tick, height);
784        draw_centered_label(image, x, 8, &tick.to_string(), label, label_bg);
785        draw_label(
786            image,
787            8,
788            y.saturating_sub(8),
789            &tick.to_string(),
790            label,
791            label_bg,
792        );
793    }
794    draw_label(image, 8, 8, "0,0", axis, label_bg);
795    let center_y = normalized_to_pixel(500, height).saturating_add(10);
796    draw_centered_label(
797        image,
798        normalized_to_pixel(500, width),
799        center_y,
800        "500,500",
801        axis,
802        label_bg,
803    );
804    let bottom_y = height.saturating_sub(22);
805    draw_centered_label(
806        image,
807        normalized_to_pixel(1000, width),
808        bottom_y,
809        "1000,1000",
810        major,
811        label_bg,
812    );
813}
814
815fn normalized_to_pixel(value: u32, size: u32) -> u32 {
816    (((value as f64 / 1000.0) * (size.saturating_sub(1)) as f64).round() as u32)
817        .min(size.saturating_sub(1))
818}
819
820fn draw_vertical(image: &mut RgbaImage, x: u32, color: Rgba<u8>, radius: u32) {
821    let (width, height) = image.dimensions();
822    let start = x.saturating_sub(radius);
823    let end = (x + radius).min(width.saturating_sub(1));
824    for px in start..=end {
825        for y in 0..height {
826            blend_pixel(image, px, y, color);
827        }
828    }
829}
830
831fn draw_horizontal(image: &mut RgbaImage, y: u32, color: Rgba<u8>, radius: u32) {
832    let (width, height) = image.dimensions();
833    let start = y.saturating_sub(radius);
834    let end = (y + radius).min(height.saturating_sub(1));
835    for py in start..=end {
836        for x in 0..width {
837            blend_pixel(image, x, py, color);
838        }
839    }
840}
841
842fn draw_square(image: &mut RgbaImage, x: u32, y: u32, color: Rgba<u8>, radius: u32) {
843    let (width, height) = image.dimensions();
844    let x_start = x.saturating_sub(radius);
845    let x_end = (x + radius).min(width.saturating_sub(1));
846    let y_start = y.saturating_sub(radius);
847    let y_end = (y + radius).min(height.saturating_sub(1));
848    for px in x_start..=x_end {
849        for py in y_start..=y_end {
850            blend_pixel(image, px, py, color);
851        }
852    }
853}
854
855fn draw_centered_label(
856    image: &mut RgbaImage,
857    center_x: u32,
858    y: u32,
859    text: &str,
860    color: Rgba<u8>,
861    background: Rgba<u8>,
862) {
863    let (image_width, _) = image.dimensions();
864    let label_width = text_width(text).saturating_add(4);
865    let x = center_x
866        .saturating_sub(label_width / 2)
867        .min(image_width.saturating_sub(label_width.saturating_add(1)));
868    draw_label(image, x, y, text, color, background);
869}
870
871fn draw_label(
872    image: &mut RgbaImage,
873    x: u32,
874    y: u32,
875    text: &str,
876    color: Rgba<u8>,
877    background: Rgba<u8>,
878) {
879    let (width, height) = image.dimensions();
880    let text_width = text_width(text);
881    let bg_width = text_width.saturating_add(4);
882    let bg_height = 16;
883    let x = x.min(width.saturating_sub(1));
884    let y = y.min(height.saturating_sub(1));
885    let x_end = x.saturating_add(bg_width).min(width.saturating_sub(1));
886    let y_end = y.saturating_add(bg_height).min(height.saturating_sub(1));
887    for px in x..=x_end {
888        for py in y..=y_end {
889            blend_pixel(image, px, py, background);
890        }
891    }
892    let mut cursor = x.saturating_add(2);
893    for ch in text.chars() {
894        draw_char(image, cursor, y.saturating_add(3), ch, color);
895        cursor = cursor.saturating_add(char_width(ch).saturating_add(2));
896    }
897}
898
899fn text_width(text: &str) -> u32 {
900    text.chars()
901        .map(|ch| char_width(ch).saturating_add(2))
902        .sum::<u32>()
903        .saturating_sub(2)
904}
905
906fn char_width(ch: char) -> u32 {
907    match ch {
908        ',' | '.' => 2,
909        '-' => 4,
910        _ => 8,
911    }
912}
913
914fn draw_char(image: &mut RgbaImage, x: u32, y: u32, ch: char, color: Rgba<u8>) {
915    let Some(pattern) = glyph(ch) else {
916        return;
917    };
918    for (row, bits) in pattern.iter().enumerate() {
919        for col in 0..5 {
920            if (bits & (1 << (4 - col))) != 0 {
921                let px = x.saturating_add((col * 2) as u32);
922                let py = y.saturating_add((row * 2) as u32);
923                draw_square(image, px, py, color, 1);
924            }
925        }
926    }
927}
928
929fn glyph(ch: char) -> Option<[u8; 5]> {
930    match ch {
931        '0' => Some([0b11111, 0b10001, 0b10001, 0b10001, 0b11111]),
932        '1' => Some([0b00100, 0b01100, 0b00100, 0b00100, 0b01110]),
933        '2' => Some([0b11111, 0b00001, 0b11111, 0b10000, 0b11111]),
934        '3' => Some([0b11111, 0b00001, 0b11111, 0b00001, 0b11111]),
935        '4' => Some([0b10001, 0b10001, 0b11111, 0b00001, 0b00001]),
936        '5' => Some([0b11111, 0b10000, 0b11111, 0b00001, 0b11111]),
937        '6' => Some([0b11111, 0b10000, 0b11111, 0b10001, 0b11111]),
938        '7' => Some([0b11111, 0b00001, 0b00010, 0b00100, 0b00100]),
939        '8' => Some([0b11111, 0b10001, 0b11111, 0b10001, 0b11111]),
940        '9' => Some([0b11111, 0b10001, 0b11111, 0b00001, 0b11111]),
941        ',' => Some([0b00, 0b00, 0b00, 0b10, 0b10]),
942        '-' => Some([0b00000, 0b00000, 0b11110, 0b00000, 0b00000]),
943        _ => None,
944    }
945}
946
947fn blend_pixel(image: &mut RgbaImage, x: u32, y: u32, overlay: Rgba<u8>) {
948    let alpha = overlay[3] as f32 / 255.0;
949    let pixel = image.get_pixel_mut(x, y);
950    for channel in 0..3 {
951        pixel[channel] = ((overlay[channel] as f32 * alpha)
952            + (pixel[channel] as f32 * (1.0 - alpha)))
953            .round() as u8;
954    }
955    pixel[3] = 255;
956}
957
958#[cfg(target_os = "macos")]
959#[repr(C)]
960#[derive(Clone, Copy)]
961struct CGPoint {
962    x: f64,
963    y: f64,
964}
965
966#[cfg(target_os = "macos")]
967#[repr(C)]
968#[derive(Clone, Copy)]
969struct CGSize {
970    width: f64,
971    height: f64,
972}
973
974#[cfg(target_os = "macos")]
975#[repr(C)]
976#[derive(Clone, Copy)]
977struct CGRect {
978    origin: CGPoint,
979    size: CGSize,
980}
981
982#[cfg(target_os = "macos")]
983#[link(name = "CoreGraphics", kind = "framework")]
984unsafe extern "C" {
985    fn CGGetActiveDisplayList(
986        max_displays: u32,
987        active_displays: *mut u32,
988        display_count: *mut u32,
989    ) -> i32;
990    fn CGGetOnlineDisplayList(
991        max_displays: u32,
992        online_displays: *mut u32,
993        display_count: *mut u32,
994    ) -> i32;
995    fn CGMainDisplayID() -> u32;
996    fn CGDisplayBounds(display: u32) -> CGRect;
997    fn CGDisplayPixelsWide(display: u32) -> usize;
998    fn CGDisplayPixelsHigh(display: u32) -> usize;
999}
1000
1001trait Pipe: Sized {
1002    fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
1003        f(self)
1004    }
1005}
1006
1007impl<T> Pipe for T {}
1008
1009#[cfg(test)]
1010mod tests {
1011    use super::*;
1012
1013    #[test]
1014    fn parses_xrandr_screen_topology() {
1015        let screens = parse_xrandr_screens(
1016            "Screen 0: minimum 8 x 8, current 3200 x 1080, maximum 32767 x 32767\n\
1017             HDMI-1 connected 1920x1080+1280+0 normal left inverted right x axis y axis\n\
1018             eDP-1 connected primary 1280x800+0+0 normal left inverted right x axis y axis\n",
1019        );
1020
1021        assert_eq!(screens.len(), 2);
1022        assert_eq!(screens[0].screen_id, "screen-1");
1023        assert_eq!(screens[0].name, "eDP-1");
1024        assert!(screens[0].is_primary);
1025        assert_eq!(screens[0].native_width, 1280);
1026        assert_eq!(screens[0].native_height, 800);
1027        assert_eq!(screens[1].screen_id, "screen-2");
1028        assert_eq!(screens[1].origin_x, 1280);
1029    }
1030
1031    #[test]
1032    fn parses_xvfb_xrandr_default_screen() {
1033        let screens = parse_xrandr_screens(
1034            "Screen 0: minimum 1 x 1, current 1280 x 800, maximum 8192 x 8192\n\
1035             screen connected primary 1280x800+0+0 0mm x 0mm\n",
1036        );
1037
1038        assert_eq!(screens.len(), 1);
1039        assert_eq!(screens[0].screen_id, "screen-1");
1040        assert_eq!(screens[0].native_width, 1280);
1041        assert_eq!(screens[0].native_height, 800);
1042    }
1043
1044    #[test]
1045    fn parses_xdpyinfo_dimensions_fallback() {
1046        let screens = parse_xdpyinfo_screens(
1047            "name of display:    :99\n\
1048             screen #0:\n\
1049               dimensions:    1440x900 pixels (381x238 millimeters)\n",
1050        );
1051
1052        assert_eq!(screens.len(), 1);
1053        assert_eq!(screens[0].screen_id, "screen-1");
1054        assert_eq!(screens[0].native_width, 1440);
1055        assert_eq!(screens[0].native_height, 900);
1056    }
1057
1058    #[test]
1059    fn adaptive_limit_preserves_minimum_short_edge() {
1060        let limit = ImageSizeLimit::Adaptive {
1061            min_long_edge: 800,
1062            min_short_edge: 500,
1063        };
1064
1065        assert_eq!(scaled_dimensions(1710, 1107, limit), (800, 518));
1066        assert_eq!(scaled_dimensions(2560, 1440, limit), (889, 500));
1067        assert_eq!(scaled_dimensions(3440, 1440, limit), (1195, 500));
1068        assert_eq!(scaled_dimensions(5120, 1440, limit), (1778, 500));
1069    }
1070
1071    #[test]
1072    fn adaptive_limit_never_upscales_small_screens() {
1073        let limit = ImageSizeLimit::Adaptive {
1074            min_long_edge: 800,
1075            min_short_edge: 500,
1076        };
1077
1078        assert_eq!(scaled_dimensions(640, 400, limit), (640, 400));
1079    }
1080}