1use crate::{
2 AwwwBackend, Colors, Config, Desktop, FileInfo, Monitor,
3 Orientation::{Horizontal, Vertical},
4 ProceduralEffect, U8Extension, WallSwitchError, WallSwitchResult, detect_monitors,
5 is_installed,
6};
7use image::{RgbImage, imageops::FilterType};
8use std::{
9 io::Error,
10 process::{Command, Output},
11};
12
13pub trait WallpaperBackend {
16 fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>>;
19
20 fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
25 let mut commands = Self::build_commands(images, config)?;
26 for cmd in commands.iter_mut() {
27 let program_name = cmd.get_program().to_string_lossy().to_string();
28 if config.dry_run {
29 println!("[DRY-RUN] Would execute: {:?}", cmd);
30 } else {
31 exec_cmd(cmd, config.verbose, &format!("Executing {program_name}"))?;
32 }
33 }
34 Ok(())
35 }
36}
37
38pub fn set_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
40 let needs_compilation = config.desktop == Desktop::Gnome
42 || config.effect != ProceduralEffect::None
43 || config.monitors.iter().any(|m| m.pictures_per_monitor > 1);
44
45 let compiled_images = if needs_compilation {
46 compile_wallpapers_for_monitors(images, config)?
47 } else {
48 images.to_vec()
49 };
50
51 match config.desktop {
53 Desktop::Gnome => {
54 if config.dry_run {
56 println!(
57 "[DRY-RUN] Would stitch compiled monitor canvases together to generate final spanned wallpaper."
58 );
59 } else {
60 let final_wallpaper = assemble_final_wallpaper(&compiled_images, config)?;
63 final_wallpaper
64 .save(&config.wallpaper)
65 .map_err(|e| WallSwitchError::Io(Error::other(e)))?;
66
67 if config.verbose {
68 println!("Stitched wallpaper saved to Gnome: {:?}", config.wallpaper);
69 }
70 }
71
72 GnomeBackend::apply(&compiled_images, config)?;
73 }
74 Desktop::Xfce => XfceBackend::apply(&compiled_images, config)?,
75 Desktop::Hyprland => HyprlandBackend::apply(&compiled_images, config)?,
76
77 Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
78 if is_installed("awww") {
79 AwwwBackend::apply(&compiled_images, config)?;
80 } else if is_installed("swaybg") {
81 SwaybgBackend::apply(&compiled_images, config)?;
82 } else if is_installed("hyprpaper") {
83 HyprlandBackend::apply(&compiled_images, config)?;
84 } else {
85 return Err(WallSwitchError::MissingWaylandTools);
86 }
87 }
88
89 Desktop::Openbox => OpenboxBackend::apply(&compiled_images, config)?,
90 }
91
92 Ok(())
93}
94
95pub struct GnomeBackend;
100
101impl WallpaperBackend for GnomeBackend {
102 fn build_commands(_images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
103 let mut commands = Vec::new();
104
105 for picture in ["picture-uri", "picture-uri-dark"] {
107 let mut cmd = Command::new("gsettings");
108 cmd.args(["set", "org.gnome.desktop.background", picture])
109 .arg(&config.wallpaper);
110 commands.push(cmd);
111 }
112
113 let mut cmd = Command::new("gsettings");
115 cmd.args([
116 "set",
117 "org.gnome.desktop.background",
118 "picture-options",
119 "spanned",
120 ]);
121 commands.push(cmd);
122
123 Ok(commands)
124 }
125}
126
127pub struct XfceBackend;
128
129impl WallpaperBackend for XfceBackend {
130 fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
131 let mut commands = Vec::new();
132 let monitors = detect_monitors(config)?;
133
134 if config.verbose {
135 println!("monitors:\n{monitors:#?}");
136 }
137
138 for (image, monitor) in images.iter().cycle().zip(monitors) {
140 let mut cmd = Command::new("xfconf-query");
141 cmd.args([
142 "--channel",
143 "xfce4-desktop",
144 "--property",
145 &monitor,
146 "--create",
147 "--type",
148 "string",
149 "--set",
150 ])
151 .arg(&image.path);
152
153 commands.push(cmd);
154 }
155
156 Ok(commands)
157 }
158}
159
160pub struct OpenboxBackend;
161
162impl WallpaperBackend for OpenboxBackend {
163 fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
164 let mut feh_cmd = Command::new(&config.path_feh);
165
166 for image in images {
167 feh_cmd.arg("--bg-fill").arg(&image.path);
168 }
169
170 Ok(vec![feh_cmd])
171 }
172}
173
174pub struct SwaybgBackend;
175
176impl WallpaperBackend for SwaybgBackend {
177 fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
178 Ok(vec![])
179 }
180
181 fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
182 let monitors = detect_monitors(config)?;
183
184 if config.verbose {
185 println!("monitors:\n{monitors:#?}");
186 }
187
188 if config.dry_run {
189 println!("[DRY-RUN] Would execute: pkill swaybg");
190 } else {
191 let _ = Command::new("pkill").arg("swaybg").output();
192 }
193
194 let mut cmd = Command::new("swaybg");
195 for (image, monitor) in images.iter().cycle().zip(&monitors) {
196 let path_str = image.path.to_str().unwrap_or_default();
197 cmd.arg("-o")
198 .arg(monitor)
199 .arg("-i")
200 .arg(path_str)
201 .arg("-m")
202 .arg("fill");
203 }
204
205 if config.verbose {
206 let program = cmd.get_program();
207 let arguments: Vec<_> = cmd.get_args().collect::<Vec<_>>();
208 println!("\nprogram: {program:?}");
209 println!("arguments: {arguments:#?}");
210 }
211
212 if config.dry_run {
213 println!("[DRY-RUN] Would spawn swaybg daemon: {:?}", cmd);
214 } else {
215 cmd.stdout(std::process::Stdio::null())
216 .stderr(std::process::Stdio::null())
217 .spawn()
218 .map_err(WallSwitchError::Io)?;
219 }
220
221 Ok(())
222 }
223}
224
225pub struct HyprlandBackend;
226
227impl WallpaperBackend for HyprlandBackend {
228 fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
229 Ok(vec![])
230 }
231
232 fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
233 let monitors = detect_monitors(config)?;
234
235 if config.verbose {
236 println!("monitors:\n{monitors:#?}");
237 }
238
239 let mut check_cmd = Command::new("hyprctl");
240 check_cmd.args(["hyprpaper", "listloaded"]);
241
242 let loaded_str = match check_cmd.output() {
243 Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
244 Err(_) => {
245 if config.dry_run {
246 "[DRY-RUN] hyprpaper daemon is offline".to_string()
247 } else {
248 return Err(WallSwitchError::UnableToFind(
249 "hyprpaper daemon not running".into(),
250 ));
251 }
252 }
253 };
254
255 for (image, monitor) in images.iter().cycle().zip(&monitors) {
256 let path_str = image.path.to_str().unwrap_or_default();
257
258 if !loaded_str.contains(path_str) {
259 let mut preload_cmd = Command::new("hyprctl");
260 preload_cmd.args(["hyprpaper", "preload", path_str]);
261
262 if config.verbose {
263 println!("\nprogram: {:?}", preload_cmd.get_program());
264 println!(
265 "arguments: {:#?}",
266 preload_cmd.get_args().collect::<Vec<_>>()
267 );
268 }
269 if config.dry_run {
270 println!("[DRY-RUN] Would execute: {:?}", preload_cmd);
271 } else {
272 let _ = preload_cmd.output();
273 }
274 }
275
276 let mut wall_cmd = Command::new("hyprctl");
277 let wall_arg = format!("{monitor},{path_str}");
278 wall_cmd.args(["hyprpaper", "wallpaper", &wall_arg]);
279
280 if config.dry_run {
281 println!("[DRY-RUN] Would execute: {:?}", wall_cmd);
282 } else {
283 exec_cmd(
284 &mut wall_cmd,
285 config.verbose,
286 &format!("Apply wallpaper on {monitor}"),
287 )?;
288 }
289 }
290
291 let mut unload_cmd = Command::new("hyprctl");
292 unload_cmd.args(["hyprpaper", "unload", "unused"]);
293 if config.dry_run {
294 println!("[DRY-RUN] Would execute: {:?}", unload_cmd);
295 } else {
296 let _ = unload_cmd.output();
297 }
298
299 Ok(())
300 }
301}
302
303struct LayoutTarget {
308 base_w: u64,
309 base_h: u64,
310 rem_w: usize,
311 rem_h: usize,
312}
313
314impl LayoutTarget {
315 fn calculate(monitor: &crate::Monitor) -> Result<Self, std::num::TryFromIntError> {
316 let mut width = monitor.resolution.width;
317 let mut height = monitor.resolution.height;
318 let pics_per_monitor = monitor.pictures_per_monitor.to_u64();
319
320 let rem_w = (width % pics_per_monitor).try_into()?;
321 let rem_h = (height % pics_per_monitor).try_into()?;
322
323 match monitor.picture_orientation {
324 Horizontal => height /= pics_per_monitor,
325 Vertical => width /= pics_per_monitor,
326 }
327
328 Ok(Self {
329 base_w: width,
330 base_h: height,
331 rem_w,
332 rem_h,
333 })
334 }
335}
336
337fn apply_selected_effect(canvas: &mut RgbImage, monitor: &Monitor, config: &Config, index: usize) {
339 if config.effect == ProceduralEffect::None {
340 return;
341 }
342
343 let resolved = config.effect.resolve();
345
346 if let Some(renderer) = resolved.get_renderer(monitor) {
348 if config.verbose {
349 let idx = index.to_string().bold().cyan();
350 let name = resolved.get_name().bold().blue();
351
352 println!("Applying to Monitor {idx} {name} {}", renderer.info());
354 }
355
356 renderer.apply(canvas);
358 }
359}
360
361fn compile_single_monitor_background(
363 partition: &[FileInfo],
364 monitor: &crate::Monitor,
365 config: &Config,
366 index: usize,
367) -> WallSwitchResult<FileInfo> {
368 let output_path = std::env::temp_dir().join(format!("wallswitch_monitor_{index}.jpg"));
369
370 if config.dry_run {
371 if config.verbose {
372 println!(
373 "[DRY-RUN] Would compile backgrounds for Monitor {index} at resolution {}x{}",
374 monitor.resolution.width, monitor.resolution.height
375 );
376 if config.effect != ProceduralEffect::None {
377 println!(
378 "[DRY-RUN] Would apply randomized overlay effect: {:?}",
379 config.effect
380 );
381 }
382 }
383 } else {
384 let mut monitor_canvas = assemble_monitor_canvas(partition, monitor)?;
386
387 if config.effect != ProceduralEffect::None {
389 apply_selected_effect(&mut monitor_canvas, monitor, config, index);
390 }
391
392 monitor_canvas
394 .save(&output_path)
395 .map_err(|e| WallSwitchError::Io(Error::other(e)))?;
396
397 if config.verbose {
398 println!("Monitor {index} background assembled: {:?}", output_path);
399 }
400 }
401
402 Ok(FileInfo {
404 path: output_path,
405 size: 0,
406 mtime: 0,
407 hash: String::new(),
408 dimension: Some(crate::Dimension {
409 width: monitor.resolution.width,
410 height: monitor.resolution.height,
411 }),
412 is_valid: Some(true),
413 number: index + 1,
414 total: config.monitors.len(),
415 })
416}
417
418pub fn compile_wallpapers_for_monitors(
420 images: &[FileInfo],
421 config: &Config,
422) -> WallSwitchResult<Vec<FileInfo>> {
423 if config.verbose {
424 if config.dry_run {
425 println!("[DRY-RUN] Would assemble multi-monitor wallpaper in pure Rust ...");
426 } else {
427 println!("Assembling multi-monitor wallpaper in pure Rust ...");
428 }
429 }
430
431 let partitions: Vec<_> = get_partitions_iter(images, config).collect();
432 let mut compiled_files = Vec::new();
433
434 std::thread::scope(|scope| {
435 let mut threads = Vec::new();
436
437 for (index, (partition, monitor)) in
438 partitions.into_iter().zip(&config.monitors).enumerate()
439 {
440 let thread_handle = scope.spawn(move || -> WallSwitchResult<FileInfo> {
442 compile_single_monitor_background(partition, monitor, config, index)
443 });
444 threads.push(thread_handle);
445 }
446
447 for handle in threads {
448 let file_info = handle.join().unwrap()?;
449 compiled_files.push(file_info);
450 }
451
452 Ok::<(), crate::WallSwitchError>(())
453 })?;
454
455 Ok(compiled_files)
456}
457
458fn assemble_monitor_canvas(
460 partition: &[FileInfo],
461 monitor: &crate::Monitor,
462) -> WallSwitchResult<RgbImage> {
463 let mut monitor_canvas = RgbImage::new(
464 monitor.resolution.width as u32,
465 monitor.resolution.height as u32,
466 );
467 let target = LayoutTarget::calculate(monitor)?;
468
469 let mut current_x = 0;
470 let mut current_y = 0;
471
472 for (p_idx, image_info) in partition.iter().enumerate() {
473 let mut w = target.base_w;
474 let mut h = target.base_h;
475
476 match monitor.picture_orientation {
477 Horizontal => {
478 if p_idx < target.rem_h {
479 h += 1;
480 }
481 }
482 Vertical => {
483 if p_idx < target.rem_w {
484 w += 1;
485 }
486 }
487 }
488
489 let resized = {
492 let img =
494 image::open(&image_info.path).map_err(|err| WallSwitchError::CorruptImage {
495 path: image_info.path.clone(),
496 source: err,
497 })?;
498
499 img.resize_to_fill(w as u32, h as u32, FilterType::Triangle)
501 .to_rgb8()
502 };
503
504 image::imageops::overlay(
506 &mut monitor_canvas,
507 &resized,
508 current_x as i64,
509 current_y as i64,
510 );
511
512 match monitor.picture_orientation {
514 Horizontal => {
515 current_y += h;
516 }
517 Vertical => {
518 current_x += w;
519 }
520 }
521 }
522
523 Ok(monitor_canvas)
524}
525
526fn assemble_final_wallpaper(
530 compiled_images: &[FileInfo],
531 config: &Config,
532) -> WallSwitchResult<RgbImage> {
533 let mut total_w = 0;
534 let mut total_h = 0;
535
536 for monitor in &config.monitors {
537 match config.monitor_orientation {
538 Horizontal => {
539 total_w += monitor.resolution.width;
540 total_h = total_h.max(monitor.resolution.height);
541 }
542 Vertical => {
543 total_w = total_w.max(monitor.resolution.width);
544 total_h += monitor.resolution.height;
545 }
546 }
547 }
548
549 let mut final_canvas = RgbImage::new(total_w as u32, total_h as u32);
550 let mut current_x = 0;
551 let mut current_y = 0;
552
553 for (idx, img_info) in compiled_images.iter().enumerate() {
554 let img = image::open(&img_info.path)
556 .map_err(|e| {
557 WallSwitchError::UnableToFind(format!(
558 "Failed to load compiled monitor canvas: {e}"
559 ))
560 })?
561 .to_rgb8();
562
563 image::imageops::overlay(&mut final_canvas, &img, current_x as i64, current_y as i64);
564
565 match config.monitor_orientation {
566 Horizontal => {
567 current_x += config.monitors[idx].resolution.width;
568 }
569 Vertical => {
570 current_y += config.monitors[idx].resolution.height;
571 }
572 }
573 }
574
575 Ok(final_canvas)
576}
577
578fn get_partitions_iter<'a>(
579 mut images: &'a [FileInfo],
580 config: &'a Config,
581) -> impl Iterator<Item = &'a [FileInfo]> {
582 config.monitors.iter().map(move |monitor| {
583 let (head, tail) = images.split_at(monitor.pictures_per_monitor.into());
584 images = tail;
585 head
586 })
587}
588
589pub fn exec_cmd(cmd: &mut Command, verbose: bool, msg: &str) -> WallSwitchResult<Output> {
590 let output: Output = cmd.output().map_err(|e| {
591 eprintln!("Failed to execute command: {:?}", cmd.get_program());
592 WallSwitchError::Io(e)
593 })?;
594
595 let program = cmd.get_program();
596 let arguments: Vec<_> = cmd.get_args().collect();
597
598 if !output.status.success() || verbose {
599 println!("\nprogram: {program:?}");
600 println!("arguments: {arguments:#?}");
601
602 let stdout = String::from_utf8_lossy(&output.stdout);
603
604 if !stdout.trim().is_empty() {
605 println!("stdout:'{}'\n", stdout.trim());
606 }
607 }
608
609 if !output.status.success() {
610 let stderr = String::from_utf8_lossy(&output.stderr);
611 let status = output.status;
612
613 eprintln!("{msg} status: {status}");
614 eprintln!("{msg} stderr: {stderr}");
615
616 return Err(WallSwitchError::CommandFailed {
617 program: format!("{:?}", cmd.get_program()),
618 status: output.status.to_string(),
619 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
620 });
621 }
622
623 Ok(output)
624}