use crate::{
AwwwBackend, Colors, CommandExt, Config, Desktop, Dimension, Environment, FileInfo,
HyprlandBackend, Monitor,
Orientation::{Horizontal, Vertical},
ProceduralEffect, SwaybgBackend, U8Extension, WALLPAPER_A, WALLPAPER_B, WallSwitchError,
WallSwitchResult, detect_monitors, is_installed,
};
use image::{RgbImage, imageops::FilterType};
use rayon::prelude::*; use std::{
io::Error,
path::{Path, PathBuf},
process::Command,
};
pub trait WallpaperBackend {
fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
Ok(vec![])
}
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();
cmd.run_with_config(config, &format!("Executing {program_name}"))?;
}
Ok(())
}
}
pub fn set_wallpaper(
images: &[FileInfo],
config: &Config,
env: &Environment,
) -> WallSwitchResult<()> {
let compiled_images = compile_wallpapers_for_monitors(images, config, env)?;
match config.desktop {
Desktop::Gnome => GnomeBackend::apply(&compiled_images, config)?,
Desktop::Xfce => XfceBackend::apply(&compiled_images, config)?,
Desktop::Hyprland => {
if is_installed("hyprpaper") {
HyprlandBackend::apply(&compiled_images, config)?;
} else if is_installed("awww") {
AwwwBackend::apply(&compiled_images, config)?;
} else if is_installed("swaybg") {
SwaybgBackend::apply(&compiled_images, config)?;
} else {
return Err(WallSwitchError::MissingWaylandTools);
}
}
Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
if is_installed("awww") {
AwwwBackend::apply(&compiled_images, config)?;
} else if is_installed("swaybg") {
SwaybgBackend::apply(&compiled_images, config)?;
} else {
return Err(WallSwitchError::MissingWaylandTools);
}
}
Desktop::Openbox => OpenboxBackend::apply(&compiled_images, config)?,
}
Ok(())
}
pub struct GnomeBackend;
impl GnomeBackend {
pub fn build_commands_for_path(wallpaper_path: &Path) -> Vec<Command> {
let wallpaper_uri = format!("file://{}", wallpaper_path.display());
let mut commands = Vec::with_capacity(3);
for key in ["picture-uri", "picture-uri-dark"] {
let mut cmd = Command::new("gsettings");
cmd.args(["set", "org.gnome.desktop.background", key, &wallpaper_uri]);
commands.push(cmd);
}
let mut span_cmd = Command::new("gsettings");
span_cmd.args([
"set",
"org.gnome.desktop.background",
"picture-options",
"spanned",
]);
commands.push(span_cmd);
commands
}
}
impl WallpaperBackend for GnomeBackend {
fn build_commands(_images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
let target_path = toggle_ping_pong_path(&config.wallpaper);
Ok(Self::build_commands_for_path(&target_path))
}
fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
let target_path = toggle_ping_pong_path(&config.wallpaper);
if config.dry_run {
println!(
"[DRY-RUN] Would stitch and save final spanned wallpaper to: {:?}",
target_path
);
} else {
let final_wallpaper = assemble_final_wallpaper(images, config)?;
final_wallpaper
.save(&target_path)
.map_err(|e| WallSwitchError::Io(Error::other(e)))?;
if config.verbose {
println!(
"Stitched wallpaper saved to Gnome (Ping-Pong): {:?}",
target_path
);
}
}
let mut commands = Self::build_commands_for_path(&target_path);
for cmd in commands.iter_mut() {
cmd.run_with_config(config, "Executing gsettings")?;
}
Ok(())
}
}
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:#?}");
}
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>> {
if config.desktop.is_wayland() {
return Err(WallSwitchError::CommandFailed {
program: "feh".to_string(),
status: "skipped".to_string(),
stderr: "feh cannot run inside a Wayland session. Use a Wayland backend (awww, swaybg, hyprpaper) or native DE tools (GNOME/XFCE).".to_string(),
});
}
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 fn toggle_ping_pong_path(current_path: &Path) -> PathBuf {
if !current_path.exists() {
return current_path.with_file_name(WALLPAPER_A);
}
let is_wallpaper_a = current_path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(WALLPAPER_A));
if is_wallpaper_a {
current_path.with_file_name(WALLPAPER_B)
} else {
current_path.with_file_name(WALLPAPER_A)
}
}
struct LayoutTarget {
base_w: u64,
base_h: u64,
rem_w: usize,
rem_h: usize,
}
impl LayoutTarget {
fn calculate(monitor: &Monitor) -> Result<Self, std::num::TryFromIntError> {
let mut width = monitor.resolution.width.max(1);
let mut height = monitor.resolution.height.max(1);
let pics_per_monitor = monitor.pictures_per_monitor.to_u64().max(1);
let rem_w = (width % pics_per_monitor).try_into()?;
let rem_h = (height % pics_per_monitor).try_into()?;
match monitor.picture_orientation {
Horizontal => height /= pics_per_monitor,
Vertical => width /= pics_per_monitor,
}
Ok(Self {
base_w: width.max(1),
base_h: height.max(1),
rem_w,
rem_h,
})
}
}
fn apply_selected_effect(
canvas: &mut RgbImage,
monitor: &Monitor,
config: &Config,
index: usize,
) -> WallSwitchResult<()> {
if config.effect == ProceduralEffect::None {
return Ok(());
}
let resolved = config.effect.resolve();
if let Some(renderer) = resolved.get_renderer(monitor, config)? {
if config.verbose {
let idx = index.to_string().bold().cyan();
let name = resolved.get_name().bold().blue();
println!("Applying to Monitor {idx} {name} {}", renderer.info());
}
renderer.apply(canvas);
}
Ok(())
}
fn compile_single_monitor_background(
partition: &[FileInfo],
monitor: &Monitor,
config: &Config,
env: &Environment,
index: usize,
) -> WallSwitchResult<FileInfo> {
let cache_dir = env.get_app_cache_dir();
if !config.dry_run {
std::fs::create_dir_all(&cache_dir).map_err(WallSwitchError::Io)?;
}
let output_path = cache_dir.join(format!("wallswitch_monitor_{index}.png"));
if config.dry_run {
if config.verbose {
println!(
"[DRY-RUN] Would compile backgrounds for Monitor {index} at resolution {}x{}",
monitor.resolution.width, monitor.resolution.height
);
}
} else {
let mut monitor_canvas = assemble_monitor_canvas(partition, monitor)?;
if config.effect != ProceduralEffect::None {
apply_selected_effect(&mut monitor_canvas, monitor, config, index)?;
}
monitor_canvas
.save(&output_path)
.map_err(|e| WallSwitchError::Io(Error::other(e)))?;
if config.verbose {
println!("Monitor {index} background assembled: {:?}", output_path);
}
}
Ok(FileInfo {
path: output_path,
size: 0,
mtime: 0,
hash: String::new(),
dimension: Some(Dimension {
width: monitor.resolution.width,
height: monitor.resolution.height,
}),
is_valid: Some(true),
number: index + 1,
total: config.monitors.len(),
})
}
pub fn compile_wallpapers_for_monitors(
images: &[FileInfo],
config: &Config,
env: &Environment,
) -> WallSwitchResult<Vec<FileInfo>> {
if config.verbose {
if config.dry_run {
println!("[DRY-RUN] Would assemble multi-monitor wallpaper in pure Rust ...");
} else {
println!("Assembling multi-monitor wallpaper in pure Rust ...");
}
}
let partitions: Vec<&[FileInfo]> = get_partitions_iter(images, config).collect();
let compiled_files = partitions
.into_par_iter()
.zip(&config.monitors)
.enumerate()
.map(|(index, (partition, monitor))| {
compile_single_monitor_background(partition, monitor, config, env, index)
})
.collect::<WallSwitchResult<Vec<_>>>()?;
Ok(compiled_files)
}
fn assemble_monitor_canvas(
partition: &[FileInfo],
monitor: &Monitor,
) -> WallSwitchResult<RgbImage> {
let canvas_w = (monitor.resolution.width as u32).max(1);
let canvas_h = (monitor.resolution.height as u32).max(1);
let mut monitor_canvas = RgbImage::new(canvas_w, canvas_h);
let target = LayoutTarget::calculate(monitor)?;
let mut current_x = 0;
let mut current_y = 0;
for (p_idx, image_info) in partition.iter().enumerate() {
let mut w = target.base_w;
let mut h = target.base_h;
match monitor.picture_orientation {
Horizontal => {
if p_idx < target.rem_h {
h += 1;
}
}
Vertical => {
if p_idx < target.rem_w {
w += 1;
}
}
}
let resized = {
let img =
image::open(&image_info.path).map_err(|err| WallSwitchError::CorruptImage {
path: image_info.path.clone(),
source: err,
})?;
img.resize_to_fill(w as u32, h as u32, FilterType::Triangle)
.to_rgb8()
};
image::imageops::overlay(
&mut monitor_canvas,
&resized,
current_x as i64,
current_y as i64,
);
match monitor.picture_orientation {
Horizontal => {
current_y += h;
}
Vertical => {
current_x += w;
}
}
}
Ok(monitor_canvas)
}
fn assemble_final_wallpaper(
compiled_images: &[FileInfo],
config: &Config,
) -> WallSwitchResult<RgbImage> {
let mut total_w = 0;
let mut total_h = 0;
for monitor in &config.monitors {
match config.monitor_orientation {
Horizontal => {
total_w += monitor.resolution.width;
total_h = total_h.max(monitor.resolution.height);
}
Vertical => {
total_w = total_w.max(monitor.resolution.width);
total_h += monitor.resolution.height;
}
}
}
let mut final_canvas = RgbImage::new((total_w as u32).max(1), (total_h as u32).max(1));
let mut current_x = 0;
let mut current_y = 0;
for (idx, img_info) in compiled_images.iter().enumerate() {
let img = image::open(&img_info.path)
.map_err(|e| {
WallSwitchError::UnableToFind(format!(
"Failed to load compiled monitor canvas: {e}"
))
})?
.to_rgb8();
image::imageops::overlay(&mut final_canvas, &img, current_x as i64, current_y as i64);
if let Some(mon) = config.monitors.get(idx) {
match config.monitor_orientation {
Horizontal => {
current_x += mon.resolution.width;
}
Vertical => {
current_y += mon.resolution.height;
}
}
}
}
Ok(final_canvas)
}
fn get_partitions_iter<'a>(
mut images: &'a [FileInfo],
config: &'a Config,
) -> impl Iterator<Item = &'a [FileInfo]> {
config.monitors.iter().map(move |monitor| {
let count = monitor.pictures_per_monitor as usize;
let (head, tail) = images.split_at_checked(count).unwrap_or((images, &[]));
images = tail;
head
})
}
#[cfg(test)]
mod tests_wallpaper {
use super::*;
use crate::{Dimension, Orientation};
use std::fs;
#[test]
fn test_toggle_ping_pong_path() {
let temp_dir = std::env::temp_dir().join("wallswitch_toggle_test");
let _ = fs::create_dir_all(&temp_dir);
let path_a = temp_dir.join(WALLPAPER_A);
let path_b = temp_dir.join(WALLPAPER_B);
let _ = fs::remove_file(&path_a);
let _ = fs::remove_file(&path_b);
assert_eq!(toggle_ping_pong_path(&path_a), path_a);
fs::write(&path_a, b"buffer A").unwrap();
assert_eq!(toggle_ping_pong_path(&path_a), path_b);
fs::write(&path_b, b"buffer B").unwrap();
assert_eq!(toggle_ping_pong_path(&path_b), path_a);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_gnome_build_commands_for_path() {
let target = Path::new("/tmp/wallswitch_a.png");
let commands = GnomeBackend::build_commands_for_path(target);
assert_eq!(commands.len(), 3, "Expected exactly 3 GSettings directives");
for cmd in &commands {
assert_eq!(
cmd.get_program(),
"gsettings",
"Target binary must be gsettings"
);
}
let expected_uri = "file:///tmp/wallswitch_a.png";
let has_light_uri = commands.iter().any(|cmd| {
let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
args.contains(&"picture-uri".into()) && args.contains(&expected_uri.into())
});
let has_dark_uri = commands.iter().any(|cmd| {
let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
args.contains(&"picture-uri-dark".into()) && args.contains(&expected_uri.into())
});
let has_spanned = commands.iter().any(|cmd| {
let args: Vec<_> = cmd.get_args().map(|a| a.to_string_lossy()).collect();
args.contains(&"picture-options".into()) && args.contains(&"spanned".into())
});
assert!(
has_light_uri,
"GSettings picture-uri command missing or malformed"
);
assert!(
has_dark_uri,
"GSettings picture-uri-dark command missing or malformed"
);
assert!(
has_spanned,
"GSettings spanned layout command missing or malformed"
);
}
#[test]
fn test_openbox_backend_wayland_guard() {
let config = Config {
desktop: Desktop::Wayland,
..Config::default()
};
let images = vec![];
let result = OpenboxBackend::build_commands(&images, &config);
assert!(
result.is_err(),
"OpenboxBackend must fail fast when running inside a Wayland compositor"
);
}
#[test]
fn test_layout_target_calculation() {
let monitor = Monitor {
picture_orientation: Orientation::Horizontal,
pictures_per_monitor: 2,
resolution: Dimension {
width: 3840,
height: 2160,
},
};
let target = LayoutTarget::calculate(&monitor).expect("Layout geometry calculation failed");
assert_eq!(target.base_w, 3840);
assert_eq!(
target.base_h, 1080,
"Height should be bisected into two 1080p partitions"
);
assert_eq!(target.rem_h, 0);
assert_eq!(target.rem_w, 0);
}
#[test]
fn test_get_partitions_iter_safety() {
let monitor1 = Monitor {
pictures_per_monitor: 2,
..Monitor::default()
};
let monitor2 = Monitor {
pictures_per_monitor: 1,
..Monitor::default()
};
let config = Config {
monitors: vec![monitor1, monitor2],
..Config::default()
};
let dummy_images = vec![
FileInfo {
number: 1,
..FileInfo::default()
},
FileInfo {
number: 2,
..FileInfo::default()
},
FileInfo {
number: 3,
..FileInfo::default()
},
];
let partitions: Vec<_> = get_partitions_iter(&dummy_images, &config).collect();
assert_eq!(partitions.len(), 2, "Expected 2 monitor partitions");
assert_eq!(partitions[0].len(), 2, "Monitor 1 expects 2 images");
assert_eq!(partitions[1].len(), 1, "Monitor 2 expects 1 image");
}
}