wallswitch 0.57.0

randomly selects wallpapers for multiple monitors
Documentation
use crate::{
    AwwwBackend, Config, Desktop, FileInfo,
    Orientation::{Horizontal, Vertical},
    U8Extension, WallSwitchError, WallSwitchResult, detect_monitors, is_installed,
};
use std::process::{Command, Output, Stdio};

/// Core trait defining the wallpaper application logic.
/// Follows the "Functional Core, Imperative Shell" pattern.
pub trait WallpaperBackend {
    /// PURE FUNCTION: Only constructs the required system commands.
    /// Does NOT execute them. This makes the logic highly testable and predictable.
    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>>;

    /// IMPURE FUNCTION: Executes the built commands.
    /// It defaults to sequentially running `build_commands`, but can be
    /// overridden by compositors that require complex state checks
    /// (e.g., Hyprland preloading, Swaybg daemon spawning).
    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
        let mut commands = Self::build_commands(images, config)?;
        for cmd in commands.iter_mut() {
            let program_name = cmd.get_program().to_string_lossy().to_string();
            exec_cmd(cmd, config.verbose, &format!("Executing {program_name}"))?;
        }
        Ok(())
    }
}

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

        // Handle new Wayland generic/WM environments seamlessly
        Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
            if is_installed("awww") {
                AwwwBackend::apply(images, config)?;
            } else if is_installed("swaybg") {
                SwaybgBackend::apply(images, config)?;
            } else if is_installed("hyprpaper") {
                HyprlandBackend::apply(images, config)?;
            } else {
                return Err(WallSwitchError::MissingWaylandTools);
            }
        }

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

    println!();
    Ok(())
}

// ==============================================================================
// BACKEND IMPLEMENTATIONS
// ==============================================================================

pub struct GnomeBackend;

impl WallpaperBackend for GnomeBackend {
    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
        let mut commands = Vec::new();

        // 1. ImageMagick command to create the spanned background
        commands.push(build_magick_command(images, config)?);

        // 2. GSettings commands to set the picture URIs
        for picture in ["picture-uri", "picture-uri-dark"] {
            let mut cmd = Command::new("gsettings");
            cmd.args(["set", "org.gnome.desktop.background", picture])
                .arg(&config.wallpaper);
            commands.push(cmd);
        }

        // 3. GSettings command to set the picture options to spanned
        let mut cmd = Command::new("gsettings");
        cmd.args([
            "set",
            "org.gnome.desktop.background",
            "picture-options",
            "spanned",
        ]);
        commands.push(cmd);

        Ok(commands)
    }
}

pub struct XfceBackend;

impl WallpaperBackend for XfceBackend {
    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
        let mut commands = Vec::new();
        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) {
            let mut cmd = Command::new("xfconf-query");
            cmd.args([
                "--channel",
                "xfce4-desktop",
                "--property",
                &monitor,
                "--create",
                "--type",
                "string",
                "--set",
            ])
            .arg(&image.path);

            commands.push(cmd);
        }

        Ok(commands)
    }
}

pub struct OpenboxBackend;

impl WallpaperBackend for OpenboxBackend {
    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
        let mut feh_cmd = Command::new(&config.path_feh);

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

        Ok(vec![feh_cmd])
    }
}

pub struct SwaybgBackend;

impl WallpaperBackend for SwaybgBackend {
    fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
        // Swaybg requires spawning a background daemon,
        // so we override the `apply` method instead of building blocking commands.
        Ok(vec![])
    }

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

        // Ensure no previous swaybg instance is running
        let _ = Command::new("pkill").arg("swaybg").output();

        let mut cmd = Command::new("swaybg");
        // Cycle through images to ensure all monitors get a wallpaper,
        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:#?}");
        }

        // Must use spawn() to keep the background service alive
        cmd.stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
            .map_err(WallSwitchError::Io)?;

        Ok(())
    }
}

pub struct HyprlandBackend;

impl WallpaperBackend for HyprlandBackend {
    fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
        // Highly stateful backend requiring IPC logic.
        // We override `apply` directly.
        Ok(vec![])
    }

    fn apply(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(
                &mut wall_cmd,
                config.verbose,
                &format!("Apply wallpaper on {monitor}"),
            )?;
        }

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

        Ok(())
    }
}

// ==============================================================================
// INTERNAL HELPERS
// ==============================================================================

/// Generates a pure ImageMagick Command to span images across monitors
fn build_magick_command(images: &[FileInfo], config: &Config) -> WallSwitchResult<Command> {
    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);
        }
    }

    Ok(magick_cmd)
}

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().map_err(|e| {
        eprintln!("Failed to execute command: {:?}", cmd.get_program());
        WallSwitchError::Io(e)
    })?;

    let program = cmd.get_program();
    let arguments: Vec<_> = cmd.get_args().collect();

    if !output.status.success() || verbose {
        println!("program: {program:?}");
        println!("arguments: {arguments:#?}\n");

        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}");

        return Err(WallSwitchError::CommandFailed {
            program: format!("{:?}", cmd.get_program()),
            status: output.status.to_string(),
            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
        });
    }

    Ok(output)
}