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