wallswitch 0.56.1

randomly selects wallpapers for multiple monitors
Documentation
use crate::{
    Config, Desktop, FileInfo,
    Orientation::{Horizontal, Vertical},
    U8Extension, WallSwitchError, WallSwitchResult, detect_monitors, set_awww_wallpaper,
};
use std::{
    // cmp::Ordering,
    path::PathBuf,
    process::{Command, Output, Stdio},
};

/// Set desktop wallpaper based on the detected Desktop Environment.
pub fn set_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    match config.desktop {
        Desktop::Gnome => set_gnome_wallpaper(images, config)?,
        Desktop::Xfce => set_xfce_wallpaper(images, config)?,
        Desktop::Hyprland => set_hyprland_wallpaper(images, config)?,

        // Handle new Wayland generic/WM environments seamlessly
        Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
            if is_installed("awww") {
                set_awww_wallpaper(images, config)?;
            } else if is_installed("swaybg") {
                let monitors = detect_monitors(config)?;
                apply_swaybg_wallpaper(images, &monitors, config)?;
            } else if is_installed("hyprpaper") {
                set_hyprland_wallpaper(images, config)?;
            } else {
                return Err(WallSwitchError::MissingWaylandTools);
            }
        }

        Desktop::Openbox => set_openbox_wallpaper(images, config)?,
    }

    println!();
    Ok(())
}

/// Helper to check if a command exists in the system PATH.
fn is_installed(binary: &str) -> bool {
    let mut cmd = Command::new("which");
    cmd.arg(binary);

    cmd.stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Logic for applying wallpaper using swaybg.
fn apply_swaybg_wallpaper(
    images: &[FileInfo],
    monitors: &[String],
    config: &Config,
) -> WallSwitchResult<()> {
    let _ = Command::new("pkill").arg("swaybg").output();

    let mut cmd = Command::new("swaybg");
    // Cycle through images to ensure all monitors get a wallpaper,
    // even if detected monitors > configured images.
    for (image, monitor) in images.iter().cycle().zip(monitors) {
        let path_str = image.path.to_str().unwrap_or_default();
        cmd.arg("-o")
            .arg(monitor)
            .arg("-i")
            .arg(path_str)
            .arg("-m")
            .arg("fill");
    }

    if config.verbose {
        let program = cmd.get_program();
        let arguments: Vec<_> = cmd.get_args().collect::<Vec<_>>();
        println!("\nprogram: {program:?}");
        println!("arguments: {arguments:#?}");
    }

    cmd.stdout(Stdio::null())
        .stderr(Stdio::null())
        .spawn()
        .map_err(WallSwitchError::Io)?;

    Ok(())
}

/// Native Hyprland logic using hyprctl and hyprpaper daemon
fn set_hyprland_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    let monitors = detect_monitors(config)?;

    // 1. Check if daemon is alive
    let mut check_cmd = Command::new("hyprctl");
    check_cmd.args(["hyprpaper", "listloaded"]);

    let loaded_str = match check_cmd.output() {
        Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
        Err(_) => {
            return Err(WallSwitchError::UnableToFind(
                "hyprpaper daemon not running".into(),
            ));
        }
    };

    // 2. Preload and Wallpaper loop
    // Cycle images to ensure all monitors are covered.
    for (image, monitor) in images.iter().cycle().zip(&monitors) {
        let path_str = image.path.to_str().unwrap_or_default();

        if !loaded_str.contains(path_str) {
            let mut preload_cmd = Command::new("hyprctl");
            preload_cmd.args(["hyprpaper", "preload", path_str]);

            if config.verbose {
                println!("\nprogram: {:?}", preload_cmd.get_program());
                println!(
                    "arguments: {:#?}",
                    preload_cmd.get_args().collect::<Vec<_>>()
                );
            }
            let _ = preload_cmd.output();
        }

        let mut wall_cmd = Command::new("hyprctl");
        let wall_arg = format!("{monitor},{path_str}");
        wall_cmd.args(["hyprpaper", "wallpaper", &wall_arg]);

        // exec_cmd already handles its own verbose logging
        exec_cmd(
            &mut wall_cmd,
            config.verbose,
            &format!("Apply wallpaper on {monitor}"),
        )?;
    }

    // 3. Cleanup
    let mut unload_cmd = Command::new("hyprctl");
    unload_cmd.args(["hyprpaper", "unload", "unused"]);

    let _ = unload_cmd.output();

    Ok(())
}

fn set_xfce_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    let monitors = detect_monitors(config)?;

    if config.verbose {
        println!("monitors:\n{monitors:#?}");
    }

    // Cycle through images so that even if XFCE detects more monitors
    // than we have prepared images for, every active monitor is fully covered.
    for (image, monitor) in images.iter().cycle().zip(monitors) {
        apply_xfconf(&image.path, &monitor, config)?;
    }

    Ok(())
}

fn apply_xfconf(path: &PathBuf, monitor: &str, config: &Config) -> WallSwitchResult<()> {
    let mut cmd = Command::new("xfconf-query");
    let xfconf = cmd
        .args([
            "--channel",
            "xfce4-desktop",
            "--property",
            monitor,
            "--create",
            "--type",
            "string",
            "--set",
        ])
        .arg(path);

    let msg = format!("apply_xfconf: xfconf {monitor}");

    exec_cmd(xfconf, config.verbose, &msg)?;

    Ok(())
}

fn set_openbox_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    let mut feh_cmd = Command::new(&config.path_feh);

    for image in images {
        feh_cmd.arg("--bg-fill").arg(&image.path);
    }

    exec_cmd(&mut feh_cmd, config.verbose, "feh")?;

    Ok(())
}

/**
Create custom background image

To join images horizontally: +append
To join images vertically: -append
To see gravity options: magick -list gravity
*/
fn set_gnome_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    create_background_image(images, config)?;

    for picture in ["picture-uri", "picture-uri-dark"] {
        let mut cmd = Command::new("gsettings");
        let gsettings = cmd
            .args(["set", "org.gnome.desktop.background", picture])
            .arg(&config.wallpaper);

        let msg = format!("gsettings {picture}");
        exec_cmd(gsettings, config.verbose, &msg)?;
    }

    let mut cmd = Command::new("gsettings");
    let spanned = cmd.args([
        "set",
        "org.gnome.desktop.background",
        "picture-options",
        "spanned",
    ]);

    exec_cmd(spanned, config.verbose, "spanned")?;

    Ok(())
}

/**
Create custom background image

To join images horizontally: +append
To join images vertically: -append
To see gravity options: magick -list gravity

### Example.
Consider 3 images in the directory: "fig01.webp", "fig02.avif" and "fig03.jpg".

Two distinct cases:

- case 1. N Monitors with the same resolution (3840x2160):

magick fig0* -gravity Center -resize 3840x2160^ -extent 3840x2160 +append wallpaper.jpg

or with aspect ratio: 16:9

magick fig0* -gravity Center -resize 3840x2160^ -extent 16:9 +append wallpaper.jpg

- case 2. 3 Monitors with different resolutions (3840x2160, 1920x1080 and 3840x2160):

magick fig01* -gravity Center -resize 3840x2160^ -extent 3840x2160 wallpaper_01.jpg \
magick fig02* -gravity Center -resize 1920x1080^ -extent 1920x1080 wallpaper_02.jpg \
magick fig03* -gravity Center -resize 3840x2160^ -extent 3840x2160 wallpaper_03.jpg \
magick -gravity South wallpaper_0*.jpg +append wallpaper.jpg

ImageMagick can run multiple operations on separate instances in a single command:

magick -gravity Center \
\( fig01* -resize 3840x2160^ -extent 3840x2160 \) \
\( fig02* -resize 1920x1080^ -extent 1920x1080 \) \
\( fig03* -resize 3840x2160^ -extent 3840x2160 \) \
-gravity South +append wallpaper.jpg

<https://www.imagemagick.org/script/command-line-processing.php>
*/
fn create_background_image(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
    let mut magick_cmd = Command::new(&config.path_magick);

    get_partitions_iter(images, config)
        .zip(&config.monitors)
        .try_for_each(|(images, monitor)| -> WallSwitchResult<()> {
            let mut width: u64 = monitor.resolution.width;
            let mut height: u64 = monitor.resolution.height;

            let pictures_per_monitor = monitor.pictures_per_monitor.to_u64();

            let remainder_w: usize = (width % pictures_per_monitor).try_into()?;
            let remainder_h: usize = (height % pictures_per_monitor).try_into()?;

            match monitor.picture_orientation {
                Horizontal => height /= pictures_per_monitor,
                Vertical => width /= pictures_per_monitor,
            }

            magick_cmd.args(["(", "-gravity", "Center"]);

            images.iter().enumerate().for_each(|(index, image)| {
                let mut w = width;
                let mut h = height;

                match monitor.picture_orientation {
                    Horizontal => {
                        if index < remainder_h {
                            h += 1;
                        }
                    }
                    Vertical => {
                        if index < remainder_w {
                            w += 1;
                        }
                    }
                }

                let resize = format!("{w}x{h}^");
                let extent = format!("{w}x{h}");

                magick_cmd
                    .arg("(")
                    .arg(&image.path)
                    .args(["-resize", &resize])
                    .args(["-extent", &extent])
                    .arg(")");
            });

            match monitor.picture_orientation {
                Horizontal => {
                    magick_cmd.args(["-gravity", "South", "-append", ")"]);
                }
                Vertical => {
                    magick_cmd.args(["-gravity", "South", "+append", ")"]);
                }
            }

            Ok(())
        })?;

    match config.monitor_orientation {
        Horizontal => {
            magick_cmd.arg("+append").arg(&config.wallpaper);
        }
        Vertical => {
            magick_cmd.arg("-append").arg(&config.wallpaper);
        }
    }

    exec_cmd(&mut magick_cmd, config.verbose, "magick")?;

    Ok(())
}

fn get_partitions_iter<'a>(
    mut images: &'a [FileInfo],
    config: &'a Config,
) -> impl Iterator<Item = &'a [FileInfo]> {
    config.monitors.iter().map(move |monitor| {
        let (head, tail) = images.split_at(monitor.pictures_per_monitor.into());
        images = tail;
        head
    })
}

pub fn exec_cmd(cmd: &mut Command, verbose: bool, msg: &str) -> WallSwitchResult<Output> {
    let output: Output = cmd.output().inspect_err(|error| {
        eprintln!("fn exec_cmd()");
        eprintln!("cmd: {cmd:?}");
        eprintln!("Error: {error}");
    })?;

    if !output.status.success() || verbose {
        let program = cmd.get_program();
        let arguments: Vec<_> = cmd.get_args().collect();

        println!("\nprogram: {program:?}");
        println!("arguments: {arguments:#?}");

        let stdout = String::from_utf8_lossy(&output.stdout);

        if !stdout.trim().is_empty() {
            println!("stdout:'{}'\n", stdout.trim());
        }
    }

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        let status = output.status;

        eprintln!("{msg} status: {status}");
        eprintln!("{msg} stderr: {stderr}");

        panic!("{stderr:?}");
    }

    Ok(output)
}