Skip to main content

wallswitch/backends/
wallpaper.rs

1use crate::{
2    AwwwBackend, Config, Desktop, FileInfo,
3    Orientation::{Horizontal, Vertical},
4    U8Extension, WallSwitchError, WallSwitchResult, detect_monitors, is_installed,
5};
6use std::process::{Command, Output, Stdio};
7
8/// Core trait defining the wallpaper application logic.
9/// Follows the "Functional Core, Imperative Shell" pattern.
10pub trait WallpaperBackend {
11    /// PURE FUNCTION: Only constructs the required system commands.
12    /// Does NOT execute them. This makes the logic highly testable and predictable.
13    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>>;
14
15    /// IMPURE FUNCTION: Executes the built commands.
16    /// It defaults to sequentially running `build_commands`, but can be
17    /// overridden by compositors that require complex state checks
18    /// (e.g., Hyprland preloading, Swaybg daemon spawning).
19    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
20        let mut commands = Self::build_commands(images, config)?;
21        for cmd in commands.iter_mut() {
22            let program_name = cmd.get_program().to_string_lossy().to_string();
23            exec_cmd(cmd, config.verbose, &format!("Executing {program_name}"))?;
24        }
25        Ok(())
26    }
27}
28
29/// Set desktop wallpaper based on the detected Desktop Environment.
30pub fn set_wallpaper(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
31    match config.desktop {
32        Desktop::Gnome => GnomeBackend::apply(images, config)?,
33        Desktop::Xfce => XfceBackend::apply(images, config)?,
34        Desktop::Hyprland => HyprlandBackend::apply(images, config)?,
35
36        // Handle new Wayland generic/WM environments seamlessly
37        Desktop::Niri | Desktop::Labwc | Desktop::Mango | Desktop::Wayland => {
38            if is_installed("awww") {
39                AwwwBackend::apply(images, config)?;
40            } else if is_installed("swaybg") {
41                SwaybgBackend::apply(images, config)?;
42            } else if is_installed("hyprpaper") {
43                HyprlandBackend::apply(images, config)?;
44            } else {
45                return Err(WallSwitchError::MissingWaylandTools);
46            }
47        }
48
49        Desktop::Openbox => OpenboxBackend::apply(images, config)?,
50    }
51
52    println!();
53    Ok(())
54}
55
56// ==============================================================================
57// BACKEND IMPLEMENTATIONS
58// ==============================================================================
59
60pub struct GnomeBackend;
61
62impl WallpaperBackend for GnomeBackend {
63    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
64        let mut commands = Vec::new();
65
66        // 1. ImageMagick command to create the spanned background
67        commands.push(build_magick_command(images, config)?);
68
69        // 2. GSettings commands to set the picture URIs
70        for picture in ["picture-uri", "picture-uri-dark"] {
71            let mut cmd = Command::new("gsettings");
72            cmd.args(["set", "org.gnome.desktop.background", picture])
73                .arg(&config.wallpaper);
74            commands.push(cmd);
75        }
76
77        // 3. GSettings command to set the picture options to spanned
78        let mut cmd = Command::new("gsettings");
79        cmd.args([
80            "set",
81            "org.gnome.desktop.background",
82            "picture-options",
83            "spanned",
84        ]);
85        commands.push(cmd);
86
87        Ok(commands)
88    }
89}
90
91pub struct XfceBackend;
92
93impl WallpaperBackend for XfceBackend {
94    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
95        let mut commands = Vec::new();
96        let monitors = detect_monitors(config)?;
97
98        if config.verbose {
99            println!("monitors:\n{monitors:#?}");
100        }
101
102        // Cycle through images so that even if XFCE detects more monitors
103        // than we have prepared images for, every active monitor is fully covered.
104        for (image, monitor) in images.iter().cycle().zip(monitors) {
105            let mut cmd = Command::new("xfconf-query");
106            cmd.args([
107                "--channel",
108                "xfce4-desktop",
109                "--property",
110                &monitor,
111                "--create",
112                "--type",
113                "string",
114                "--set",
115            ])
116            .arg(&image.path);
117
118            commands.push(cmd);
119        }
120
121        Ok(commands)
122    }
123}
124
125pub struct OpenboxBackend;
126
127impl WallpaperBackend for OpenboxBackend {
128    fn build_commands(images: &[FileInfo], config: &Config) -> WallSwitchResult<Vec<Command>> {
129        let mut feh_cmd = Command::new(&config.path_feh);
130
131        for image in images {
132            feh_cmd.arg("--bg-fill").arg(&image.path);
133        }
134
135        Ok(vec![feh_cmd])
136    }
137}
138
139pub struct SwaybgBackend;
140
141impl WallpaperBackend for SwaybgBackend {
142    fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
143        // Swaybg requires spawning a background daemon,
144        // so we override the `apply` method instead of building blocking commands.
145        Ok(vec![])
146    }
147
148    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
149        let monitors = detect_monitors(config)?;
150
151        if config.verbose {
152            println!("monitors:\n{monitors:#?}");
153        }
154
155        // Ensure no previous swaybg instance is running
156        let _ = Command::new("pkill").arg("swaybg").output();
157
158        let mut cmd = Command::new("swaybg");
159        // Cycle through images to ensure all monitors get a wallpaper,
160        for (image, monitor) in images.iter().cycle().zip(&monitors) {
161            let path_str = image.path.to_str().unwrap_or_default();
162            cmd.arg("-o")
163                .arg(monitor)
164                .arg("-i")
165                .arg(path_str)
166                .arg("-m")
167                .arg("fill");
168        }
169
170        if config.verbose {
171            let program = cmd.get_program();
172            let arguments: Vec<_> = cmd.get_args().collect::<Vec<_>>();
173            println!("\nprogram: {program:?}");
174            println!("arguments: {arguments:#?}");
175        }
176
177        // Must use spawn() to keep the background service alive
178        cmd.stdout(Stdio::null())
179            .stderr(Stdio::null())
180            .spawn()
181            .map_err(WallSwitchError::Io)?;
182
183        Ok(())
184    }
185}
186
187pub struct HyprlandBackend;
188
189impl WallpaperBackend for HyprlandBackend {
190    fn build_commands(_images: &[FileInfo], _config: &Config) -> WallSwitchResult<Vec<Command>> {
191        // Highly stateful backend requiring IPC logic.
192        // We override `apply` directly.
193        Ok(vec![])
194    }
195
196    fn apply(images: &[FileInfo], config: &Config) -> WallSwitchResult<()> {
197        let monitors = detect_monitors(config)?;
198
199        if config.verbose {
200            println!("monitors:\n{monitors:#?}");
201        }
202
203        // 1. Check if daemon is alive
204        let mut check_cmd = Command::new("hyprctl");
205        check_cmd.args(["hyprpaper", "listloaded"]);
206
207        let loaded_str = match check_cmd.output() {
208            Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
209            Err(_) => {
210                return Err(WallSwitchError::UnableToFind(
211                    "hyprpaper daemon not running".into(),
212                ));
213            }
214        };
215
216        // 2. Preload and Wallpaper loop
217        // Cycle images to ensure all monitors are covered.
218        for (image, monitor) in images.iter().cycle().zip(&monitors) {
219            let path_str = image.path.to_str().unwrap_or_default();
220
221            if !loaded_str.contains(path_str) {
222                let mut preload_cmd = Command::new("hyprctl");
223                preload_cmd.args(["hyprpaper", "preload", path_str]);
224
225                if config.verbose {
226                    println!("\nprogram: {:?}", preload_cmd.get_program());
227                    println!(
228                        "arguments: {:#?}",
229                        preload_cmd.get_args().collect::<Vec<_>>()
230                    );
231                }
232                let _ = preload_cmd.output();
233            }
234
235            let mut wall_cmd = Command::new("hyprctl");
236            let wall_arg = format!("{monitor},{path_str}");
237            wall_cmd.args(["hyprpaper", "wallpaper", &wall_arg]);
238
239            exec_cmd(
240                &mut wall_cmd,
241                config.verbose,
242                &format!("Apply wallpaper on {monitor}"),
243            )?;
244        }
245
246        // 3. Cleanup unused images from RAM
247        let mut unload_cmd = Command::new("hyprctl");
248        unload_cmd.args(["hyprpaper", "unload", "unused"]);
249        let _ = unload_cmd.output();
250
251        Ok(())
252    }
253}
254
255// ==============================================================================
256// INTERNAL HELPERS
257// ==============================================================================
258
259/// Generates a pure ImageMagick Command to span images across monitors
260fn build_magick_command(images: &[FileInfo], config: &Config) -> WallSwitchResult<Command> {
261    let mut magick_cmd = Command::new(&config.path_magick);
262
263    get_partitions_iter(images, config)
264        .zip(&config.monitors)
265        .try_for_each(|(images, monitor)| -> WallSwitchResult<()> {
266            let mut width: u64 = monitor.resolution.width;
267            let mut height: u64 = monitor.resolution.height;
268
269            let pictures_per_monitor = monitor.pictures_per_monitor.to_u64();
270
271            let remainder_w: usize = (width % pictures_per_monitor).try_into()?;
272            let remainder_h: usize = (height % pictures_per_monitor).try_into()?;
273
274            match monitor.picture_orientation {
275                Horizontal => height /= pictures_per_monitor,
276                Vertical => width /= pictures_per_monitor,
277            }
278
279            magick_cmd.args(["(", "-gravity", "Center"]);
280
281            images.iter().enumerate().for_each(|(index, image)| {
282                let mut w = width;
283                let mut h = height;
284
285                match monitor.picture_orientation {
286                    Horizontal => {
287                        if index < remainder_h {
288                            h += 1;
289                        }
290                    }
291                    Vertical => {
292                        if index < remainder_w {
293                            w += 1;
294                        }
295                    }
296                }
297
298                let resize = format!("{w}x{h}^");
299                let extent = format!("{w}x{h}");
300
301                magick_cmd
302                    .arg("(")
303                    .arg(&image.path)
304                    .args(["-resize", &resize])
305                    .args(["-extent", &extent])
306                    .arg(")");
307            });
308
309            match monitor.picture_orientation {
310                Horizontal => {
311                    magick_cmd.args(["-gravity", "South", "-append", ")"]);
312                }
313                Vertical => {
314                    magick_cmd.args(["-gravity", "South", "+append", ")"]);
315                }
316            }
317
318            Ok(())
319        })?;
320
321    match config.monitor_orientation {
322        Horizontal => {
323            magick_cmd.arg("+append").arg(&config.wallpaper);
324        }
325        Vertical => {
326            magick_cmd.arg("-append").arg(&config.wallpaper);
327        }
328    }
329
330    Ok(magick_cmd)
331}
332
333fn get_partitions_iter<'a>(
334    mut images: &'a [FileInfo],
335    config: &'a Config,
336) -> impl Iterator<Item = &'a [FileInfo]> {
337    config.monitors.iter().map(move |monitor| {
338        let (head, tail) = images.split_at(monitor.pictures_per_monitor.into());
339        images = tail;
340        head
341    })
342}
343
344pub fn exec_cmd(cmd: &mut Command, verbose: bool, msg: &str) -> WallSwitchResult<Output> {
345    let output: Output = cmd.output().map_err(|e| {
346        eprintln!("Failed to execute command: {:?}", cmd.get_program());
347        WallSwitchError::Io(e)
348    })?;
349
350    let program = cmd.get_program();
351    let arguments: Vec<_> = cmd.get_args().collect();
352
353    if !output.status.success() || verbose {
354        println!("program: {program:?}");
355        println!("arguments: {arguments:#?}\n");
356
357        let stdout = String::from_utf8_lossy(&output.stdout);
358
359        if !stdout.trim().is_empty() {
360            println!("stdout:'{}'\n", stdout.trim());
361        }
362    }
363
364    if !output.status.success() {
365        let stderr = String::from_utf8_lossy(&output.stderr);
366        let status = output.status;
367
368        eprintln!("{msg} status: {status}");
369        eprintln!("{msg} stderr: {stderr}");
370
371        return Err(WallSwitchError::CommandFailed {
372            program: format!("{:?}", cmd.get_program()),
373            status: output.status.to_string(),
374            stderr: String::from_utf8_lossy(&output.stderr).to_string(),
375        });
376    }
377
378    Ok(output)
379}