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::*; use std::{
11 io::Error,
12 path::{Path, PathBuf},
13 process::Command,
14};
15
16pub trait WallpaperBackend {
22 fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
24 Ok(vec![])
25 }
26
27 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 cmd.run_with_config(config, &format!("Executing {program_name}"))?;
38 }
39 Ok(())
40 }
41}
42
43pub fn set_wallpaper(
52 images: &[FileInfo],
53 config: &Config,
54 env: &Environment,
55) -> WallSwitchResult<()> {
56 let compiled_images = compile_wallpapers_for_monitors(images, config, env)?;
58
59 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
92pub struct GnomeBackend;
98
99impl GnomeBackend {
100 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 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 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 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 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 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 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
170pub 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 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
204pub struct OpenboxBackend;
206
207impl WallpaperBackend for OpenboxBackend {
208 fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
219 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 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
238pub fn toggle_ping_pong_path(current_path: &Path) -> PathBuf {
251 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
268struct 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
302fn 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 let resolved = config.effect.resolve();
315
316 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 println!("Applying to Monitor {idx} {name} {}", renderer.info());
324 }
325
326 renderer.apply(canvas);
328 }
329
330 Ok(())
331}
332
333fn 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 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 let mut monitor_canvas = assemble_monitor_canvas(partition, monitor)?;
360
361 if config.effect != ProceduralEffect::None {
363 apply_selected_effect(&mut monitor_canvas, monitor, config, index)?;
364 }
365
366 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 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
392pub 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 let partitions: Vec<&[FileInfo]> = get_partitions_iter(images, config).collect();
408
409 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
422fn 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 let resized = {
456 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 img.resize_to_fill(w as u32, h as u32, FilterType::Triangle)
465 .to_rgb8()
466 };
467
468 image::imageops::overlay(
470 &mut monitor_canvas,
471 &resized,
472 current_x as i64,
473 current_y as i64,
474 );
475
476 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
490fn 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 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
542fn 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 let (head, tail) = images.split_at_checked(count).unwrap_or((images, &[]));
567
568 images = tail;
570
571 head
573 })
574}
575
576#[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 assert_eq!(toggle_ping_pong_path(&path_a), path_a);
600
601 fs::write(&path_a, b"buffer A").unwrap();
603 assert_eq!(toggle_ping_pong_path(&path_a), path_b);
604
605 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 #[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 #[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 #[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 #[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}