Skip to main content

wallswitch/backends/
wallpaper.rs

1use crate::{
2    AwwwBackend, Colors, CommandExt, Config, Desktop, Dimension, Environment, FileInfo,
3    HyprlandBackend, Monitor,
4    Orientation::{Horizontal, Vertical},
5    ProceduralEffect, SwaybgBackend, U8Extension, WALLPAPER_A, WALLPAPER_B, WallSwitchError,
6    WallSwitchResult, detect_monitors, is_installed,
7};
8use image::{RgbImage, imageops::FilterType};
9use rayon::prelude::*; // Required for parallel iterators
10use std::{
11    io::Error,
12    path::{Path, PathBuf},
13    process::Command,
14};
15
16/// Core trait defining the wallpaper application logic across disparate desktop environments.
17///
18/// Follows the "Functional Core, Imperative Shell" architectural pattern:
19/// - `build_commands`: Pure command construction logic.
20/// - `apply`: Execution coordinator that dispatches constructed commands to the OS.
21pub trait WallpaperBackend {
22    /// Pure function: Constructs the required system commands for the target desktop environment.
23    fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
24        Ok(vec![])
25    }
26
27    /// Impure function: Executes the constructed system commands.
28    ///
29    /// Iterates over commands generated by [`build_commands`](WallpaperBackend::build_commands)
30    /// and runs them using [`CommandExt::run_with_config`]. Can be overridden by compositors
31    /// requiring custom lifecycle logic (e.g., GNOME double-buffering, Hyprland preloading).
32    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
33        let mut commands = Self::build_commands(images, config)?;
34        for cmd in commands.iter_mut() {
35            let program_name = cmd.get_program().to_string_lossy().to_string();
36            // Using the new CommandExt trait for unified execution
37            cmd.run_with_config(config, &format!("Executing {program_name}"))?;
38        }
39        Ok(())
40    }
41}
42
43/// Orchestrates wallpaper generation and dispatches to the active desktop environment backend.
44///
45/// # Workflow
46/// 1. Pre-renders, scales, and stitches monitor canvases in parallel into cache partitions.
47/// 2. Dispatches compiled image files to the detected [`Desktop`] backend provider.
48///
49/// # Errors
50/// Returns [`WallSwitchError::MissingWaylandTools`] if no supported Wayland utility is present.
51pub fn set_wallpaper(
52    images: &[FileInfo],
53    config: &Config,
54    env: &Environment,
55) -> WallSwitchResult<()> {
56    // 1. Pre-render and compile unique monitor canvases concurrently
57    let compiled_images = compile_wallpapers_for_monitors(images, config, env)?;
58
59    // 2. Clean, single-line dispatcher per desktop environment
60    match config.desktop {
61        Desktop::Gnome => GnomeBackend::apply(&compiled_images, config)?,
62        Desktop::Xfce => XfceBackend::apply(&compiled_images, config)?,
63
64        Desktop::Hyprland => {
65            if is_installed("hyprpaper") {
66                HyprlandBackend::apply(&compiled_images, config)?;
67            } else if is_installed("awww") {
68                AwwwBackend::apply(&compiled_images, config)?;
69            } else if is_installed("swaybg") {
70                SwaybgBackend::apply(&compiled_images, config)?;
71            } else {
72                return Err(WallSwitchError::MissingWaylandTools);
73            }
74        }
75
76        Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
77            if is_installed("awww") {
78                AwwwBackend::apply(&compiled_images, config)?;
79            } else if is_installed("swaybg") {
80                SwaybgBackend::apply(&compiled_images, config)?;
81            } else {
82                return Err(WallSwitchError::MissingWaylandTools);
83            }
84        }
85
86        Desktop::Openbox => OpenboxBackend::apply(&compiled_images, config)?,
87    }
88
89    Ok(())
90}
91
92// ==============================================================================
93// BACKEND IMPLEMENTATIONS
94// ==============================================================================
95
96/// GNOME Desktop backend provider utilizing GSettings and Ping-Pong double buffering.
97pub struct GnomeBackend;
98
99impl GnomeBackend {
100    /// Builds `gsettings` commands targeting an explicit wallpaper file path.
101    ///
102    /// Adheres to the DRY principle by configuring both light (`picture-uri`)
103    /// and dark (`picture-uri-dark`) schemas, alongside the `spanned` layout option.
104    pub fn build_commands_for_path(wallpaper_path: &Path) -> Vec<Command> {
105        let wallpaper_uri = format!("file://{}", wallpaper_path.display());
106        let mut commands = Vec::with_capacity(3);
107
108        // Configure URI across both light and dark GNOME appearance styles
109        for key in ["picture-uri", "picture-uri-dark"] {
110            let mut cmd = Command::new("gsettings");
111            cmd.args(["set", "org.gnome.desktop.background", key, &wallpaper_uri]);
112            commands.push(cmd);
113        }
114
115        // Set picture-options layout to span multi-monitor setups seamlessly
116        let mut span_cmd = Command::new("gsettings");
117        span_cmd.args([
118            "set",
119            "org.gnome.desktop.background",
120            "picture-options",
121            "spanned",
122        ]);
123        commands.push(span_cmd);
124
125        commands
126    }
127}
128
129impl WallpaperBackend for GnomeBackend {
130    /// Satisfies the trait contract by constructing commands for the resolved ping-pong target.
131    fn build_commands(_images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
132        let target_path = toggle_ping_pong_path(&config.wallpaper);
133        Ok(Self::build_commands_for_path(&target_path))
134    }
135
136    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
137        // 1. Alterna para o próximo buffer em memória (A -> B ou B -> A)
138        let target_path = toggle_ping_pong_path(&config.wallpaper);
139
140        if config.dry_run {
141            println!(
142                "[DRY-RUN] Would stitch and save final spanned wallpaper to: {:?}",
143                target_path
144            );
145        } else {
146            // 2. Monta o canvas final e salva no buffer de destino
147            let final_wallpaper = assemble_final_wallpaper(images, config)?;
148            final_wallpaper
149                .save(&target_path)
150                .map_err(|e| WallSwitchError::Io(Error::other(e)))?;
151
152            if config.verbose {
153                println!(
154                    "Stitched wallpaper saved to Gnome (Ping-Pong): {:?}",
155                    target_path
156                );
157            }
158        }
159
160        // 3. Aplica os comandos apontando para a nova URI
161        let mut commands = Self::build_commands_for_path(&target_path);
162        for cmd in commands.iter_mut() {
163            cmd.run_with_config(config, "Executing gsettings")?;
164        }
165
166        Ok(())
167    }
168}
169
170/// XFCE Desktop backend provider using `xfconf-query`.
171pub struct XfceBackend;
172
173impl WallpaperBackend for XfceBackend {
174    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
175        let mut commands = Vec::new();
176        let monitors = detect_monitors(config)?;
177
178        if config.verbose {
179            println!("monitors:\n{monitors:#?}");
180        }
181
182        // Cycle through compiled single-image-per-monitor backgrounds
183        for (image, monitor) in images.iter().cycle().zip(monitors) {
184            let mut cmd = Command::new("xfconf-query");
185            cmd.args([
186                "--channel",
187                "xfce4-desktop",
188                "--property",
189                &monitor,
190                "--create",
191                "--type",
192                "string",
193                "--set",
194            ])
195            .arg(&image.path);
196
197            commands.push(cmd);
198        }
199
200        Ok(commands)
201    }
202}
203
204/// Openbox and standalone X11 Window Manager backend provider using `feh`.
205pub struct OpenboxBackend;
206
207impl WallpaperBackend for OpenboxBackend {
208    /// Builds the execution command for X11 / Openbox environments using `feh`.
209    ///
210    /// # Protocol Guard & Didactic Rationale
211    /// `feh` relies directly on the legacy X11 protocol and requires a valid `$DISPLAY`
212    /// to connect to the X Server root window. When running inside a pure Wayland session
213    /// (e.g., Mutter, Hyprland, Sway), invoking `feh` results in an immediate display
214    /// connection failure (`feh ERROR: Can't open X display`).
215    ///
216    /// This guard guarantees fail-fast execution and prevents unnecessary process spawns
217    /// by ensuring `feh` is exclusively executed under native X11 sessions.
218    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
219        // Defensive check: Prevent executing X11 tools within modern Wayland sessions
220        if config.desktop.is_wayland() {
221            return Err(WallSwitchError::CommandFailed {
222                program: "feh".to_string(),
223                status: "skipped".to_string(),
224                stderr: "feh cannot run inside a Wayland session. Use a Wayland backend (awww, swaybg, hyprpaper) or native DE tools (GNOME/XFCE).".to_string(),
225            });
226        }
227
228        // Construct the multi-monitor wallpaper assignment command for X11
229        let mut feh_cmd = Command::new(&config.path_feh);
230        for image in images {
231            feh_cmd.arg("--bg-fill").arg(&image.path);
232        }
233
234        Ok(vec![feh_cmd])
235    }
236}
237
238// ==============================================================================
239// PURE & ISOLATED UTILITY HELPERS
240// ==============================================================================
241
242/// Toggles the ping-pong double buffer path in-memory ([`WALLPAPER_A`] <-> [`WALLPAPER_B`]).
243///
244/// # Lifecycle & Self-Healing Logic
245/// 1. If the current wallpaper does not exist on disk yet (1st run ever),
246///    it guarantees the target is [`WALLPAPER_A`].
247/// 2. If it already exists, it toggles in-memory with zero heap allocations:
248///    - `_a.png` -> `_b.png`
249///    - `_b.png` -> `_a.png`
250pub fn toggle_ping_pong_path(current_path: &Path) -> PathBuf {
251    // Caso especial: se o arquivo não existe no disco (1ª execução), o alvo DEVE ser o _a!
252    if !current_path.exists() {
253        return current_path.with_file_name(WALLPAPER_A);
254    }
255
256    let is_wallpaper_a = current_path
257        .file_name()
258        .and_then(|n| n.to_str())
259        .is_some_and(|name| name.eq_ignore_ascii_case(WALLPAPER_A));
260
261    if is_wallpaper_a {
262        current_path.with_file_name(WALLPAPER_B)
263    } else {
264        current_path.with_file_name(WALLPAPER_A)
265    }
266}
267
268// ==============================================================================
269// STRUCTURAL & MATHEMATICAL GEOMETRY COMPUTATIONS (Pure Helpers)
270// ==============================================================================
271
272struct LayoutTarget {
273    base_w: u64,
274    base_h: u64,
275    rem_w: usize,
276    rem_h: usize,
277}
278
279impl LayoutTarget {
280    fn calculate(monitor: &Monitor) -> Result<Self, std::num::TryFromIntError> {
281        let mut width = monitor.resolution.width.max(1);
282        let mut height = monitor.resolution.height.max(1);
283        let pics_per_monitor = monitor.pictures_per_monitor.to_u64().max(1);
284
285        let rem_w = (width % pics_per_monitor).try_into()?;
286        let rem_h = (height % pics_per_monitor).try_into()?;
287
288        match monitor.picture_orientation {
289            Horizontal => height /= pics_per_monitor,
290            Vertical => width /= pics_per_monitor,
291        }
292
293        Ok(Self {
294            base_w: width.max(1),
295            base_h: height.max(1),
296            rem_w,
297            rem_h,
298        })
299    }
300}
301
302/// Helper function to select and apply procedural overlays in-memory.
303fn apply_selected_effect(
304    canvas: &mut RgbImage,
305    monitor: &Monitor,
306    config: &Config,
307    index: usize,
308) -> WallSwitchResult<()> {
309    if config.effect == ProceduralEffect::None {
310        return Ok(());
311    }
312
313    // 1. Resolve the effect once to prevent non-deterministic double-evaluation bugs
314    let resolved = config.effect.resolve();
315
316    // 2. Factory builds the resolved dynamic effect polymorphically (propagates Err if any)
317    if let Some(renderer) = resolved.get_renderer(monitor, config)? {
318        if config.verbose {
319            let idx = index.to_string().bold().cyan();
320            let name = resolved.get_name().bold().blue();
321
322            // Dynamic dispatch prints the customized info of each concrete struct
323            println!("Applying to Monitor {idx} {name} {}", renderer.info());
324        }
325
326        // Execute the render logic in-memory
327        renderer.apply(canvas);
328    }
329
330    Ok(())
331}
332
333/// Compiles a single monitor canvas, applies overlays, saves the output to disk, and builds its FileInfo metadata.
334fn compile_single_monitor_background(
335    partition: &[FileInfo],
336    monitor: &Monitor,
337    config: &Config,
338    env: &Environment,
339    index: usize,
340) -> WallSwitchResult<FileInfo> {
341    let cache_dir = env.get_app_cache_dir();
342
343    // Ensure the cache directory exists before writing to it
344    if !config.dry_run {
345        std::fs::create_dir_all(&cache_dir).map_err(WallSwitchError::Io)?;
346    }
347
348    let output_path = cache_dir.join(format!("wallswitch_monitor_{index}.png"));
349
350    if config.dry_run {
351        if config.verbose {
352            println!(
353                "[DRY-RUN] Would compile backgrounds for Monitor {index} at resolution {}x{}",
354                monitor.resolution.width, monitor.resolution.height
355            );
356        }
357    } else {
358        // 1. Assemble separate pictures into a single composite monitor background in-memory
359        let mut monitor_canvas = assemble_monitor_canvas(partition, monitor)?;
360
361        // 2. Overlay dynamic procedural adjustments if any are requested
362        if config.effect != ProceduralEffect::None {
363            apply_selected_effect(&mut monitor_canvas, monitor, config, index)?;
364        }
365
366        // 3. Save compiled monitor canvas to disk
367        monitor_canvas
368            .save(&output_path)
369            .map_err(|e| WallSwitchError::Io(Error::other(e)))?;
370
371        if config.verbose {
372            println!("Monitor {index} background assembled: {:?}", output_path);
373        }
374    }
375
376    // 4. Construct structural metadata representing the updated target file
377    Ok(FileInfo {
378        path: output_path,
379        size: 0,
380        mtime: 0,
381        hash: String::new(),
382        dimension: Some(Dimension {
383            width: monitor.resolution.width,
384            height: monitor.resolution.height,
385        }),
386        is_valid: Some(true),
387        number: index + 1,
388        total: config.monitors.len(),
389    })
390}
391
392/// Pre-processes and compiles separate multi-picture composite backgrounds in parallel for each monitor.
393pub fn compile_wallpapers_for_monitors(
394    images: &[FileInfo],
395    config: &Config,
396    env: &Environment,
397) -> WallSwitchResult<Vec<FileInfo>> {
398    if config.verbose {
399        if config.dry_run {
400            println!("[DRY-RUN] Would assemble multi-monitor wallpaper in pure Rust ...");
401        } else {
402            println!("Assembling multi-monitor wallpaper in pure Rust ...");
403        }
404    }
405
406    // 1. First, collect the partitions into a Vec so we can use Rayon's parallel iterator.
407    let partitions: Vec<&[FileInfo]> = get_partitions_iter(images, config).collect();
408
409    // 2. Use Rayon to process the partitions in parallel.
410    let compiled_files = partitions
411        .into_par_iter()
412        .zip(&config.monitors)
413        .enumerate()
414        .map(|(index, (partition, monitor))| {
415            compile_single_monitor_background(partition, monitor, config, env, index)
416        })
417        .collect::<WallSwitchResult<Vec<_>>>()?;
418
419    Ok(compiled_files)
420}
421
422/// Assembles multiple sub-images into a single cohesive canvas for a given monitor in-memory.
423fn assemble_monitor_canvas(
424    partition: &[FileInfo],
425    monitor: &Monitor,
426) -> WallSwitchResult<RgbImage> {
427    let canvas_w = (monitor.resolution.width as u32).max(1);
428    let canvas_h = (monitor.resolution.height as u32).max(1);
429
430    let mut monitor_canvas = RgbImage::new(canvas_w, canvas_h);
431    let target = LayoutTarget::calculate(monitor)?;
432
433    let mut current_x = 0;
434    let mut current_y = 0;
435
436    for (p_idx, image_info) in partition.iter().enumerate() {
437        let mut w = target.base_w;
438        let mut h = target.base_h;
439
440        match monitor.picture_orientation {
441            Horizontal => {
442                if p_idx < target.rem_h {
443                    h += 1;
444                }
445            }
446            Vertical => {
447                if p_idx < target.rem_w {
448                    w += 1;
449                }
450            }
451        }
452
453        // Memory optimization: Load, resize, and convert inside a nested block to drop
454        // the heavy uncompressed DynamicImage (`img`) immediately before drawing.
455        let resized = {
456            // Load the image using the image crate
457            let img =
458                image::open(&image_info.path).map_err(|err| WallSwitchError::CorruptImage {
459                    path: image_info.path.clone(),
460                    source: err,
461                })?;
462
463            // Center crop and scale preserving aspect ratio
464            img.resize_to_fill(w as u32, h as u32, FilterType::Triangle)
465                .to_rgb8()
466        };
467
468        // Draw sub-image onto the monitor canvas
469        image::imageops::overlay(
470            &mut monitor_canvas,
471            &resized,
472            current_x as i64,
473            current_y as i64,
474        );
475
476        // Adjust coordinates for the next image in the layout
477        match monitor.picture_orientation {
478            Horizontal => {
479                current_y += h;
480            }
481            Vertical => {
482                current_x += w;
483            }
484        }
485    }
486
487    Ok(monitor_canvas)
488}
489
490/// Stitches all compiled monitor canvases together to generate the final spanned multi-monitor wallpaper in-memory.
491fn assemble_final_wallpaper(
492    compiled_images: &[FileInfo],
493    config: &Config,
494) -> WallSwitchResult<RgbImage> {
495    let mut total_w = 0;
496    let mut total_h = 0;
497
498    for monitor in &config.monitors {
499        match config.monitor_orientation {
500            Horizontal => {
501                total_w += monitor.resolution.width;
502                total_h = total_h.max(monitor.resolution.height);
503            }
504            Vertical => {
505                total_w = total_w.max(monitor.resolution.width);
506                total_h += monitor.resolution.height;
507            }
508        }
509    }
510
511    let mut final_canvas = RgbImage::new((total_w as u32).max(1), (total_h as u32).max(1));
512    let mut current_x = 0;
513    let mut current_y = 0;
514
515    for (idx, img_info) in compiled_images.iter().enumerate() {
516        // Load, convert to RGB8, draw, and immediately drop to keep memory consumption low
517        let img = image::open(&img_info.path)
518            .map_err(|e| {
519                WallSwitchError::UnableToFind(format!(
520                    "Failed to load compiled monitor canvas: {e}"
521                ))
522            })?
523            .to_rgb8();
524
525        image::imageops::overlay(&mut final_canvas, &img, current_x as i64, current_y as i64);
526
527        if let Some(mon) = config.monitors.get(idx) {
528            match config.monitor_orientation {
529                Horizontal => {
530                    current_x += mon.resolution.width;
531                }
532                Vertical => {
533                    current_y += mon.resolution.height;
534                }
535            }
536        }
537    }
538
539    Ok(final_canvas)
540}
541
542/// Partitions a flat slice of images into sub-slices for each configured monitor.
543///
544/// Each monitor consumes a specified number of pictures (`pictures_per_monitor`).
545/// The iterator lazily advances through the `images` slice, dividing it into chunks
546/// corresponding to each monitor's requirements.
547///
548/// # Safety & Panic-Freedom
549///
550/// Uses [`slice::split_at_checked`] instead of `split_at` to eliminate runtime panics
551/// if fewer images are available than the monitor configuration requests.
552///
553/// - If `count <= images.len()`, the slice is split into `[0..count]` (head) and `[count..]` (tail).
554/// - If `count > images.len()`, it gracefully falls back to yielding all remaining images
555///   in `head` and leaves `tail` as an empty slice (`&[]`).
556fn get_partitions_iter<'a>(
557    mut images: &'a [FileInfo],
558    config: &'a Config,
559) -> impl Iterator<Item = &'a [FileInfo]> {
560    config.monitors.iter().map(move |monitor| {
561        let count = monitor.pictures_per_monitor as usize;
562
563        // Perform safe boundary splitting:
564        // If there are not enough images remaining, consume what is left
565        // and set the remaining tail to an empty slice (&[]).
566        let (head, tail) = images.split_at_checked(count).unwrap_or((images, &[]));
567
568        // Advance the internal cursor to the unassigned remainder of the slice
569        images = tail;
570
571        // Return the chunk allocated for the current monitor
572        head
573    })
574}
575
576//----------------------------------------------------------------------------//
577//                                   Tests                                    //
578//----------------------------------------------------------------------------//
579
580/// cargo test -- --show-output tests_wallpaper
581#[cfg(test)]
582mod tests_wallpaper {
583    use super::*;
584    use crate::{Dimension, Orientation};
585    use std::fs;
586
587    #[test]
588    fn test_toggle_ping_pong_path() {
589        let temp_dir = std::env::temp_dir().join("wallswitch_toggle_test");
590        let _ = fs::create_dir_all(&temp_dir);
591
592        let path_a = temp_dir.join(WALLPAPER_A);
593        let path_b = temp_dir.join(WALLPAPER_B);
594
595        let _ = fs::remove_file(&path_a);
596        let _ = fs::remove_file(&path_b);
597
598        // 1. Arquivo não existe no disco (1ª execução) -> DEVE retornar _A
599        assert_eq!(toggle_ping_pong_path(&path_a), path_a);
600
601        // 2. Arquivo _A existe no disco -> DEVE alternar para _B
602        fs::write(&path_a, b"buffer A").unwrap();
603        assert_eq!(toggle_ping_pong_path(&path_a), path_b);
604
605        // 3. Arquivo _B existe no disco -> DEVE alternar para _A
606        fs::write(&path_b, b"buffer B").unwrap();
607        assert_eq!(toggle_ping_pong_path(&path_b), path_a);
608
609        let _ = fs::remove_dir_all(&temp_dir);
610    }
611
612    /// Verifies that GNOME backend constructs all 3 required GSettings commands:
613    /// light mode URI, dark mode URI, and spanned layout options.
614    #[test]
615    fn test_gnome_build_commands_for_path() {
616        let target = Path::new("/tmp/wallswitch_a.png");
617        let commands = GnomeBackend::build_commands_for_path(target);
618
619        assert_eq!(commands.len(), 3, "Expected exactly 3 GSettings directives");
620
621        for cmd in &commands {
622            assert_eq!(
623                cmd.get_program(),
624                "gsettings",
625                "Target binary must be gsettings"
626            );
627        }
628
629        let expected_uri = "file:///tmp/wallswitch_a.png";
630
631        let has_light_uri = commands.iter().any(|cmd| {
632            let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
633            args.contains(&"picture-uri".into()) && args.contains(&expected_uri.into())
634        });
635
636        let has_dark_uri = commands.iter().any(|cmd| {
637            let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
638            args.contains(&"picture-uri-dark".into()) && args.contains(&expected_uri.into())
639        });
640
641        let has_spanned = commands.iter().any(|cmd| {
642            let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
643            args.contains(&"picture-options".into()) && args.contains(&"spanned".into())
644        });
645
646        assert!(
647            has_light_uri,
648            "GSettings picture-uri command missing or malformed"
649        );
650        assert!(
651            has_dark_uri,
652            "GSettings picture-uri-dark command missing or malformed"
653        );
654        assert!(
655            has_spanned,
656            "GSettings spanned layout command missing or malformed"
657        );
658    }
659
660    /// Verifies that OpenboxBackend fails fast and rejects execution under Wayland sessions.
661    #[test]
662    fn test_openbox_backend_wayland_guard() {
663        let config = Config {
664            desktop: Desktop::Wayland,
665            ..Config::default()
666        };
667
668        let images = vec![];
669        let result = OpenboxBackend::build_commands(&images, &config);
670
671        assert!(
672            result.is_err(),
673            "OpenboxBackend must fail fast when running inside a Wayland compositor"
674        );
675    }
676
677    /// Verifies mathematical coordinate slicing for multi-picture horizontal splitting.
678    #[test]
679    fn test_layout_target_calculation() {
680        let monitor = Monitor {
681            picture_orientation: Orientation::Horizontal,
682            pictures_per_monitor: 2,
683            resolution: Dimension {
684                width: 3840,
685                height: 2160,
686            },
687        };
688
689        let target = LayoutTarget::calculate(&monitor).expect("Layout geometry calculation failed");
690        assert_eq!(target.base_w, 3840);
691        assert_eq!(
692            target.base_h, 1080,
693            "Height should be bisected into two 1080p partitions"
694        );
695        assert_eq!(target.rem_h, 0);
696        assert_eq!(target.rem_w, 0);
697    }
698
699    /// Verifies safe, panic-free iterator partitioning across configured monitors.
700    #[test]
701    fn test_get_partitions_iter_safety() {
702        let monitor1 = Monitor {
703            pictures_per_monitor: 2,
704            ..Monitor::default()
705        };
706        let monitor2 = Monitor {
707            pictures_per_monitor: 1,
708            ..Monitor::default()
709        };
710        let config = Config {
711            monitors: vec![monitor1, monitor2],
712            ..Config::default()
713        };
714
715        let dummy_images = vec![
716            FileInfo {
717                number: 1,
718                ..FileInfo::default()
719            },
720            FileInfo {
721                number: 2,
722                ..FileInfo::default()
723            },
724            FileInfo {
725                number: 3,
726                ..FileInfo::default()
727            },
728        ];
729
730        let partitions: Vec<_> = get_partitions_iter(&dummy_images, &config).collect();
731        assert_eq!(partitions.len(), 2, "Expected 2 monitor partitions");
732        assert_eq!(partitions[0].len(), 2, "Monitor 1 expects 2 images");
733        assert_eq!(partitions[1].len(), 1, "Monitor 2 expects 1 image");
734    }
735}