Skip to main content

wallswitch/backends/
wallpaper.rs

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
13/// Core trait defining the wallpaper application logic.
14/// Follows the "Functional Core, Imperative Shell" pattern.
15pub trait WallpaperBackend {
16    /// PURE FUNCTION: Only constructs the required system commands.
17    /// Does NOT execute them. This makes the logic highly testable and predictable.
18    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>>;
19
20    /// IMPURE FUNCTION: Executes the built commands.
21    /// It defaults to sequentially running `build_commands`, but can be
22    /// overridden by compositors that require complex state checks
23    /// (e.g., Hyprland preloading, Swaybg daemon spawning).
24    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
38/// Set desktop wallpaper based on the detected Desktop Environment.
39pub fn set_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
40    // 1. Determine if compilation is needed (if effect is active, or Gnome, or P > 1)
41    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    // 2. Dispatch to the appropriate backend using the compiled single-image-per-monitor files
52    match config.desktop {
53        Desktop::Gnome => {
54            // Gnome requires stitching the compiled monitor images together into a single spanned file
55            if config.dry_run {
56                println!(
57                    "[DRY-RUN] Would stitch compiled monitor canvases together to generate final spanned wallpaper."
58                );
59            } else {
60                // Memory optimized: Instead of loading all canvases into an intermediate vector,
61                // we pass `&compiled_images` directly. The function now opens and overlays them sequentially.
62                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
95// ==============================================================================
96// BACKEND IMPLEMENTATIONS
97// ==============================================================================
98
99pub 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        // GSettings commands to set the background picture URIs
106        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        // GSettings command to set the picture options to spanned
114        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        // Cycle through compiled single-image-per-monitor backgrounds
139        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
303// ==============================================================================
304// STRUCTURAL & MATHEMATICAL GEOMETRY COMPUTATIONS (Pure Helpers)
305// ==============================================================================
306
307struct 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
337/// Helper function to select and apply procedural overlays in-memory.
338fn apply_selected_effect(canvas: &mut RgbImage, monitor: &Monitor, config: &Config, index: usize) {
339    if config.effect == ProceduralEffect::None {
340        return;
341    }
342
343    // 1. Resolve the effect once to prevent non-deterministic double-evaluation bugs
344    let resolved = config.effect.resolve();
345
346    // 2. Factory builds the resolved dynamic effect polymorphically
347    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            // Dynamic dispatch prints the customized info of each concrete struct
353            println!("Applying to Monitor {idx} {name} {}", renderer.info());
354        }
355
356        // Execute the render logic in-memory
357        renderer.apply(canvas);
358    }
359}
360
361/// Compiles a single monitor canvas, applies overlays, saves the output to disk, and builds its FileInfo metadata.
362fn 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        // 1. Assemble separate pictures into a single composite monitor background in-memory
385        let mut monitor_canvas = assemble_monitor_canvas(partition, monitor)?;
386
387        // 2. Overlay dynamic procedural adjustments if any are requested
388        if config.effect != ProceduralEffect::None {
389            apply_selected_effect(&mut monitor_canvas, monitor, config, index);
390        }
391
392        // 3. Save compiled monitor canvas to disk
393        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    // 4. Construct structural metadata representing the updated target file
403    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
418/// Pre-processes and compiles separate multi-picture composite backgrounds in parallel for each monitor.
419pub 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            // Spawn separate tasks for each physical display to optimize hardware efficiency
441            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
458/// Assembles multiple sub-images into a single cohesive canvas for a given monitor in-memory.
459fn 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        // Memory optimization: Load, resize, and convert inside a nested block to drop
490        // the heavy uncompressed DynamicImage (`img`) immediately before drawing.
491        let resized = {
492            // Load the image using the image crate
493            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            // Center crop and scale preserving aspect ratio (mimics magick -resize WxH^ -extent WxH)
500            img.resize_to_fill(w as u32, h as u32, FilterType::Triangle)
501                .to_rgb8()
502        };
503
504        // Draw sub-image onto the monitor canvas
505        image::imageops::overlay(
506            &mut monitor_canvas,
507            &resized,
508            current_x as i64,
509            current_y as i64,
510        );
511
512        // Adjust coordinates for the next image in the layout
513        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
526/// Stitches all compiled monitor canvases together to generate the final spanned multi-monitor wallpaper in-memory.
527/// Loads and processes each monitor image sequentially to prevent holding multiple uncompressed high-resolution
528/// buffers in memory at the same time, significantly reducing peak RSS.
529fn 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        // Load, convert to RGB8, draw, and immediately drop to keep memory consumption low
555        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}