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 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 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}