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#[derive(Args, Debug, Clone, Default)]
9pub struct EffectArgs {
10 #[arg(short = 'e', long, value_name = "name", value_parser = parse_effect_name)]
13 pub effect: Option<String>,
14
15 #[arg(short = 'd', long, value_name = "time")]
17 pub duration: Option<String>,
18
19 #[arg(short = 'o', long, value_name = "preset|x,y")]
21 pub origin: Option<String>,
22
23 #[arg(short = 'a', long, value_name = "deg")]
25 pub angle: Option<f32>,
26
27 #[arg(long, value_name = "x,y")]
29 pub direction: Option<String>,
30
31 #[arg(long, value_enum, value_name = "curve", hide_possible_values = true)]
33 pub easing: Option<crate::effect::Easing>,
34
35 #[arg(long, value_name = "val")]
37 pub from: Option<f32>,
38
39 #[arg(long, value_name = "val")]
41 pub to: Option<f32>,
42
43 #[arg(long, value_name = "hz")]
45 pub frequency: Option<f32>,
46
47 #[arg(long, value_name = "val")]
49 pub amplitude: Option<f32>,
50
51 #[arg(long, value_name = "val")]
53 pub softness: Option<f32>,
54}
55
56impl EffectArgs {
57 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
90fn 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
105fn 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
113fn 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
121pub 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 #[arg(global = true, short = 'c', long, value_name = "path")]
202 pub config: Option<PathBuf>,
203
204 #[arg(global = true, short = 'v', long, action = clap::ArgAction::Count)]
206 pub verbose: u8,
207
208 #[arg(global = true, short = 'q', long)]
210 pub quiet: bool,
211
212 #[arg(global = true, short = 'h', long, action = ArgAction::Help)]
214 pub help: Option<bool>,
215
216 #[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 #[arg(short = 'c', long, value_name = "path")]
239 pub config: Option<PathBuf>,
240
241 #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
243 pub max_fps: Option<u32>,
244
245 #[arg(short = 'v', long, action = ArgAction::Count)]
247 pub verbose: u8,
248
249 #[arg(short = 'q', long)]
251 pub quiet: bool,
252
253 #[arg(global = true, short = 'h', long, action = ArgAction::Help)]
255 pub help: Option<bool>,
256
257 #[arg(short = 'V', long, action = ArgAction::Version)]
259 pub version: Option<bool>,
260}
261
262#[derive(Subcommand, Debug)]
263pub enum Commands {
264 #[command(alias = "img")]
266 Set {
267 #[arg(value_name = "path")]
269 path: PathBuf,
270
271 #[arg(short = 'm', long, value_name = "output")]
273 monitor: Option<String>,
274
275 #[arg(long, value_enum, value_name = "mode", hide_possible_values = true)]
277 mode: Option<ScalingMode>,
278
279 #[arg(long)]
281 no_theme: bool,
282
283 #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
285 fps: Option<u32>,
286
287 #[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 #[command(flatten)]
299 effect_args: EffectArgs,
300 },
301
302 Doctor,
304
305 Config {
307 #[command(subcommand)]
308 subcommand: ConfigCommands,
309 },
310
311 Reload,
313
314 Monitor {
316 #[command(subcommand)]
317 subcommand: MonitorCommands,
318 },
319
320 Daemon {
322 #[arg(long, value_name = "fps", value_parser = clap::value_parser!(u32).range(1..=240))]
324 max_fps: Option<u32>,
325 },
326
327 Ipc {
329 #[command(subcommand)]
330 subcommand: IpcCommands,
331 },
332
333 Quit,
335}
336
337#[derive(Subcommand, Debug)]
338pub enum ConfigCommands {
339 Get {
341 #[arg(value_name = "key")]
342 key: String,
343 },
344 Set {
346 #[arg(value_name = "key")]
347 key: String,
348 #[arg(value_name = "value")]
349 value: String,
350 },
351 Path,
353}
354
355#[derive(Subcommand, Debug)]
356pub enum MonitorCommands {
357 List,
359 Current,
361}
362
363#[derive(Subcommand, Debug)]
364pub enum IpcCommands {
365 Pause {
367 #[arg(short = 'm', long)]
369 monitor: Option<String>,
370 },
371 Resume {
373 #[arg(short = 'm', long)]
375 monitor: Option<String>,
376 },
377 Reload,
379 Stop,
381 Status,
383 Info {
385 #[arg(short = 'm', long)]
387 monitor: Option<String>,
388 },
389 Seek {
391 #[arg(value_name = "timestamp")]
393 timestamp: String,
394 #[arg(short = 'm', long)]
396 monitor: Option<String>,
397 },
398 Blank {
400 #[arg(short = 'm', long)]
402 monitor: Option<String>,
403 #[command(flatten)]
404 effect_args: EffectArgs,
405 },
406 Restore {
408 #[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}