Skip to main content

wallr_common/
cli.rs

1use crate::effect::{Effect, EffectOverrides, apply_effect_overrides, effect_from_name};
2use crate::types::{ScalingMode, ThemeProvider};
3use clap::builder::styling::{AnsiColor, Styles};
4use clap::{ArgAction, Args, Parser, Subcommand};
5use std::path::PathBuf;
6
7/// transition options for wallpaper changes.
8#[derive(Args, Debug, Clone, Default)]
9pub struct EffectArgs {
10    /// transition effect: fade, wipe, slide, wave, grow, outer (plus aliases:
11    /// simple, left, right, top, bottom, center, any, random)
12    #[arg(short = 'e', long, value_name = "name", value_parser = parse_effect_name)]
13    pub effect: Option<String>,
14
15    /// transition duration (e.g. 700ms, 1s, 1.2s)
16    #[arg(short = 'd', long, value_name = "time")]
17    pub duration: Option<String>,
18
19    /// effect origin: preset (top_left, top, top_right, left, center, right, bottom_left, bottom, bottom_right) or "x,y" (0..1)
20    #[arg(short = 'o', long, value_name = "preset|x,y")]
21    pub origin: Option<String>,
22
23    /// angle for wipe/slide in degrees (0 = right-to-left, 90 = top-to-bottom, 270 = bottom-to-top)
24    #[arg(short = 'a', long, value_name = "deg")]
25    pub angle: Option<f32>,
26
27    /// direction vector "x,y" (e.g. "1,0" or "-1,0")
28    #[arg(long, value_name = "x,y")]
29    pub direction: Option<String>,
30
31    /// easing curve: linear, ease_in, ease_out, ease_in_out, bezier
32    #[arg(long, value_enum, value_name = "curve", hide_possible_values = true)]
33    pub easing: Option<crate::effect::Easing>,
34
35    /// initial parameter value (fade opacity)
36    #[arg(long, value_name = "val")]
37    pub from: Option<f32>,
38
39    /// target parameter value (fade opacity)
40    #[arg(long, value_name = "val")]
41    pub to: Option<f32>,
42
43    /// wave frequency in hz
44    #[arg(long, value_name = "hz")]
45    pub frequency: Option<f32>,
46
47    /// wave amplitude
48    #[arg(long, value_name = "val")]
49    pub amplitude: Option<f32>,
50
51    /// wipe feather softness (0.01 - 0.5)
52    #[arg(long, value_name = "val")]
53    pub softness: Option<f32>,
54}
55
56impl EffectArgs {
57    /// build an `Effect` from `--effect <name>` + all override flags.
58    /// falls back to `fallback` when no effect name is given.
59    pub fn to_effect(&self, fallback: Effect) -> Effect {
60        let mut effect = self
61            .effect
62            .as_deref()
63            .and_then(effect_from_name)
64            .unwrap_or(fallback);
65        apply_effect_overrides(&mut effect, &self.to_overrides());
66        effect
67    }
68
69    pub fn to_overrides(&self) -> EffectOverrides {
70        let origin = self.origin.as_deref().and_then(parse_origin);
71        EffectOverrides {
72            origin,
73            origin_preset: if origin.is_none() {
74                self.origin.clone()
75            } else {
76                None
77            },
78            direction: self.direction.as_deref().and_then(parse_vec2),
79            angle: self.angle,
80            easing: self.easing,
81            from: self.from,
82            to: self.to,
83            frequency: self.frequency,
84            amplitude: self.amplitude,
85            softness: self.softness,
86        }
87    }
88}
89
90/// validate `--effect`. Aliases (left, right, top, bottom, center, any,
91/// random, simple) resolve through the real parser; the error message shows
92/// only the six canonical transitions.
93fn parse_effect_name(s: &str) -> Result<String, String> {
94    if crate::effect::effect_from_name(s).is_some() {
95        Ok(s.to_string())
96    } else {
97        Err(format!(
98            "unknown effect '{}' - expected one of: {}",
99            s,
100            crate::effect::effect_names().join(", ")
101        ))
102    }
103}
104
105/// parse "x,y" into a normalized origin, or none if it's not numeric.
106fn parse_origin(s: &str) -> Option<(f32, f32)> {
107    let mut parts = s.split(',');
108    let x = parts.next()?.trim().parse::<f32>().ok()?;
109    let y = parts.next()?.trim().parse::<f32>().ok()?;
110    Some((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)))
111}
112
113/// parse "x,y" into a direction vector.
114fn parse_vec2(s: &str) -> Option<[f32; 2]> {
115    let mut parts = s.split(',');
116    let x = parts.next()?.trim().parse::<f32>().ok()?;
117    let y = parts.next()?.trim().parse::<f32>().ok()?;
118    Some([x, y])
119}
120
121/// parse an ipc seek timestamp into milliseconds.
122///
123/// accepts `SS`, `MM:SS`, or `HH:MM:SS` with optional fractional seconds for
124/// the bare-seconds form. Rejects non-finite, negative, and overflowing input
125/// instead of saturating or wrapping through a cast.
126pub fn parse_timestamp_ms(timestamp: &str) -> Result<u64, String> {
127    let timestamp = timestamp.trim();
128    if timestamp.is_empty() {
129        return Err("timestamp cannot be empty".to_string());
130    }
131    if timestamp.contains(':') {
132        let parts: Vec<&str> = timestamp.split(':').collect();
133        if !matches!(parts.len(), 2 | 3) {
134            return Err("invalid timestamp format. Use HH:MM:SS or seconds".to_string());
135        }
136        let mut values = Vec::with_capacity(parts.len());
137        for part in &parts {
138            let value: u64 = part
139                .parse()
140                .map_err(|_| format!("invalid timestamp component: {part}"))?;
141            values.push(value);
142        }
143        let seconds = if values.len() == 3 {
144            if values[1] >= 60 || values[2] >= 60 {
145                return Err("minutes and seconds must be below 60".to_string());
146            }
147            values[0]
148                .checked_mul(3600)
149                .and_then(|v| v.checked_add(values[1] * 60))
150                .and_then(|v| v.checked_add(values[2]))
151                .ok_or_else(|| "timestamp overflows".to_string())?
152        } else {
153            if values[1] >= 60 {
154                return Err("seconds must be below 60".to_string());
155            }
156            values[0]
157                .checked_mul(60)
158                .and_then(|v| v.checked_add(values[1]))
159                .ok_or_else(|| "timestamp overflows".to_string())?
160        };
161        seconds
162            .checked_mul(1000)
163            .ok_or_else(|| "timestamp overflows".to_string())
164    } else {
165        let seconds: f64 = timestamp
166            .parse()
167            .map_err(|_| format!("invalid timestamp: {timestamp}"))?;
168        if !seconds.is_finite() || seconds < 0.0 {
169            return Err("timestamp must be a finite non-negative number".to_string());
170        }
171        let millis = (seconds * 1000.0).round();
172        if millis > u64::MAX as f64 {
173            return Err("timestamp overflows".to_string());
174        }
175        Ok(millis as u64)
176    }
177}
178
179#[derive(Parser, Debug)]
180#[command(
181    name = "wallr",
182    about = "wayland wallpaper engine with gpu animations and theme pipelines",
183    version,
184    override_usage = "wallr [options] <command>",
185    disable_help_subcommand = true,
186    disable_help_flag = true,
187    disable_version_flag = true,
188    next_help_heading = "options",
189    help_template = "{before-help}{name} {version}\n\n{about}\n\nusage:\n  {usage}\n\ncommands:\n{subcommands}\n\noptions:\n{options}{after-help}",
190    styles = Styles::styled()
191        .header(AnsiColor::Cyan.on_default().bold())
192        .usage(AnsiColor::Blue.on_default().bold())
193        .literal(AnsiColor::Green.on_default().bold())
194        .placeholder(AnsiColor::Yellow.on_default())
195)]
196pub struct WallrCli {
197    #[command(subcommand)]
198    pub command: Commands,
199
200    /// custom config file path
201    #[arg(global = true, short = 'c', long, value_name = "path")]
202    pub config: Option<PathBuf>,
203
204    /// verbose logging (-v, -vv)
205    #[arg(global = true, short = 'v', long, action = clap::ArgAction::Count)]
206    pub verbose: u8,
207
208    /// quiet mode (suppress non-error output)
209    #[arg(global = true, short = 'q', long)]
210    pub quiet: bool,
211
212    /// show help
213    #[arg(global = true, short = 'h', long, action = ArgAction::Help)]
214    pub help: Option<bool>,
215
216    /// show version
217    #[arg(short = 'V', long, action = ArgAction::Version)]
218    pub version: Option<bool>,
219}
220
221#[derive(Parser, Debug)]
222#[command(
223    name = "wallr-daemon",
224    about = "wayland rendering service for wallr",
225    version,
226    disable_help_flag = true,
227    disable_version_flag = true,
228    next_help_heading = "options",
229    help_template = "{before-help}{name} {version}\n\n{about}\n\nusage:\n  {usage}\n\noptions:\n{options}{after-help}",
230    styles = Styles::styled()
231        .header(AnsiColor::Cyan.on_default().bold())
232        .usage(AnsiColor::Blue.on_default().bold())
233        .literal(AnsiColor::Green.on_default().bold())
234        .placeholder(AnsiColor::Yellow.on_default())
235)]
236pub struct WallrDaemonCli {
237    /// configuration file path
238    #[arg(short = 'c', long, value_name = "path")]
239    pub config: Option<PathBuf>,
240
241    /// cap animated wallpaper rendering (1..=240 fps)
242    #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
243    pub max_fps: Option<u32>,
244
245    /// show daemon diagnostics (-v, -vv)
246    #[arg(short = 'v', long, action = ArgAction::Count)]
247    pub verbose: u8,
248
249    /// show errors only
250    #[arg(short = 'q', long)]
251    pub quiet: bool,
252
253    /// show help
254    #[arg(global = true, short = 'h', long, action = ArgAction::Help)]
255    pub help: Option<bool>,
256
257    /// show version
258    #[arg(short = 'V', long, action = ArgAction::Version)]
259    pub version: Option<bool>,
260}
261
262#[derive(Subcommand, Debug)]
263pub enum Commands {
264    /// set wallpaper image or video
265    #[command(alias = "img")]
266    Set {
267        /// wallpaper file path (image, gif, or video); `-` reads from stdin
268        #[arg(value_name = "path")]
269        path: PathBuf,
270
271        /// target output/monitor
272        #[arg(short = 'm', long, value_name = "output")]
273        monitor: Option<String>,
274
275        /// scaling mode: fill, fit, stretch, center, tile
276        #[arg(long, value_enum, value_name = "mode", hide_possible_values = true)]
277        mode: Option<ScalingMode>,
278
279        /// disable theme extraction
280        #[arg(long)]
281        no_theme: bool,
282
283        /// cap video presentation rate (1..=240 fps)
284        #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
285        fps: Option<u32>,
286
287        /// override theme engine (matugen, wallust, pywal)
288        #[arg(
289            short = 't',
290            long,
291            value_enum,
292            value_name = "provider",
293            hide_possible_values = true
294        )]
295        theme: Option<ThemeProvider>,
296
297        /// transition overrides
298        #[command(flatten)]
299        effect_args: EffectArgs,
300    },
301
302    /// run system and dependency diagnostics
303    Doctor,
304
305    /// view and edit configuration
306    Config {
307        #[command(subcommand)]
308        subcommand: ConfigCommands,
309    },
310
311    /// reload config and restart theme hooks
312    Reload,
313
314    /// query connected outputs
315    Monitor {
316        #[command(subcommand)]
317        subcommand: MonitorCommands,
318    },
319
320    /// run background daemon
321    Daemon {
322        /// max render fps limit
323        #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
324        max_fps: Option<u32>,
325    },
326
327    /// daemon ipc controls (playback, blanking, info)
328    Ipc {
329        #[command(subcommand)]
330        subcommand: IpcCommands,
331    },
332
333    /// stop running daemon
334    Quit,
335}
336
337#[derive(Subcommand, Debug)]
338pub enum ConfigCommands {
339    /// get config value by key
340    Get {
341        #[arg(value_name = "key")]
342        key: String,
343    },
344    /// set config value by key
345    Set {
346        #[arg(value_name = "key")]
347        key: String,
348        #[arg(value_name = "value")]
349        value: String,
350    },
351    /// print path to active config file
352    Path,
353}
354
355#[derive(Subcommand, Debug)]
356pub enum MonitorCommands {
357    /// list all connected display outputs
358    List,
359    /// display current focused output
360    Current,
361}
362
363#[derive(Subcommand, Debug)]
364pub enum IpcCommands {
365    /// pause video/gif playback
366    Pause {
367        /// target specific monitor (default: all)
368        #[arg(short = 'm', long)]
369        monitor: Option<String>,
370    },
371    /// resume video/gif playback
372    Resume {
373        /// target specific monitor (default: all)
374        #[arg(short = 'm', long)]
375        monitor: Option<String>,
376    },
377    /// re-render current wallpaper
378    Reload,
379    /// shut down the daemon
380    Stop,
381    /// query daemon status
382    Status,
383    /// query gpu, video decoder, and display info
384    Info {
385        /// target specific monitor
386        #[arg(short = 'm', long)]
387        monitor: Option<String>,
388    },
389    /// seek video to position (hh:mm:ss or seconds)
390    Seek {
391        /// position in hh:mm:ss or seconds
392        #[arg(value_name = "timestamp")]
393        timestamp: String,
394        /// target specific monitor
395        #[arg(short = 'm', long)]
396        monitor: Option<String>,
397    },
398    /// blank output to black with transition
399    Blank {
400        /// target specific monitor (default: all)
401        #[arg(short = 'm', long)]
402        monitor: Option<String>,
403        #[command(flatten)]
404        effect_args: EffectArgs,
405    },
406    /// restore wallpaper on blanked output
407    Restore {
408        /// target specific monitor (default: all)
409        #[arg(short = 'm', long)]
410        monitor: Option<String>,
411        #[command(flatten)]
412        effect_args: EffectArgs,
413    },
414}
415
416#[cfg(test)]
417mod tests {
418    use super::{WallrCli, WallrDaemonCli, parse_timestamp_ms};
419    use clap::Parser;
420
421    #[test]
422    fn daemon_cli_has_a_direct_service_interface() {
423        let cli = WallrDaemonCli::try_parse_from([
424            "wallr-daemon",
425            "--config",
426            "wallr.yaml",
427            "--max-fps",
428            "60",
429            "-vv",
430        ])
431        .unwrap();
432        assert_eq!(
433            cli.config.as_deref().unwrap(),
434            std::path::Path::new("wallr.yaml")
435        );
436        assert_eq!(cli.max_fps, Some(60));
437        assert_eq!(cli.verbose, 2);
438        assert!(WallrDaemonCli::try_parse_from(["wallr-daemon", "daemon"]).is_err());
439    }
440
441    #[test]
442    fn daemon_cli_rejects_invalid_fps_limits() {
443        for fps in ["0", "241"] {
444            assert!(WallrDaemonCli::try_parse_from(["wallr-daemon", "--max-fps", fps]).is_err());
445        }
446    }
447
448    #[test]
449    fn wallpaper_cli_rejects_invalid_fps_limits() {
450        for fps in ["0", "241"] {
451            assert!(
452                WallrCli::try_parse_from(["wallr", "set", "wallpaper.png", "--fps", fps]).is_err()
453            );
454        }
455    }
456
457    #[test]
458    fn parses_seek_timestamps() {
459        assert_eq!(parse_timestamp_ms("0").unwrap(), 0);
460        assert_eq!(parse_timestamp_ms("90").unwrap(), 90_000);
461        assert_eq!(parse_timestamp_ms("1.5").unwrap(), 1_500);
462        assert_eq!(parse_timestamp_ms("01:30").unwrap(), 90_000);
463        assert_eq!(parse_timestamp_ms("01:02:03").unwrap(), 3_723_000);
464    }
465
466    #[test]
467    fn rejects_invalid_seek_timestamps() {
468        for value in ["", "-1", "NaN", "inf", "1e300", "1:60", "1:2:60", "1:2:3:4"] {
469            assert!(parse_timestamp_ms(value).is_err(), "{value} should fail");
470        }
471    }
472}