use crate::effect::{Effect, EffectOverrides, apply_effect_overrides, effect_from_name};
use crate::types::{ScalingMode, ThemeProvider};
use clap::builder::styling::{AnsiColor, Styles};
use clap::{ArgAction, Args, Parser, Subcommand};
use std::path::PathBuf;
#[derive(Args, Debug, Clone, Default)]
pub struct EffectArgs {
#[arg(short = 'e', long, value_name = "name", value_parser = parse_effect_name)]
pub effect: Option<String>,
#[arg(short = 'd', long, value_name = "time")]
pub duration: Option<String>,
#[arg(short = 'o', long, value_name = "preset|x,y")]
pub origin: Option<String>,
#[arg(short = 'a', long, value_name = "deg")]
pub angle: Option<f32>,
#[arg(long, value_name = "x,y")]
pub direction: Option<String>,
#[arg(long, value_enum)]
pub easing: Option<crate::effect::Easing>,
#[arg(long, value_name = "val")]
pub from: Option<f32>,
#[arg(long, value_name = "val")]
pub to: Option<f32>,
#[arg(long, value_name = "hz")]
pub frequency: Option<f32>,
#[arg(long, value_name = "val")]
pub amplitude: Option<f32>,
#[arg(long, value_name = "val")]
pub softness: Option<f32>,
}
impl EffectArgs {
pub fn to_effect(&self, fallback: Effect) -> Effect {
let mut effect = self
.effect
.as_deref()
.and_then(effect_from_name)
.unwrap_or(fallback);
apply_effect_overrides(&mut effect, &self.to_overrides());
effect
}
pub fn to_overrides(&self) -> EffectOverrides {
let origin = self.origin.as_deref().and_then(parse_origin);
EffectOverrides {
origin,
origin_preset: if origin.is_none() {
self.origin.clone()
} else {
None
},
direction: self.direction.as_deref().and_then(parse_vec2),
angle: self.angle,
easing: self.easing,
from: self.from,
to: self.to,
frequency: self.frequency,
amplitude: self.amplitude,
softness: self.softness,
}
}
}
fn parse_effect_name(s: &str) -> Result<String, String> {
if crate::effect::effect_from_name(s).is_some() {
Ok(s.to_string())
} else {
Err(format!(
"unknown effect '{}' - expected one of: {}",
s,
crate::effect::effect_names().join(", ")
))
}
}
fn parse_origin(s: &str) -> Option<(f32, f32)> {
let mut parts = s.split(',');
let x = parts.next()?.trim().parse::<f32>().ok()?;
let y = parts.next()?.trim().parse::<f32>().ok()?;
Some((x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)))
}
fn parse_vec2(s: &str) -> Option<[f32; 2]> {
let mut parts = s.split(',');
let x = parts.next()?.trim().parse::<f32>().ok()?;
let y = parts.next()?.trim().parse::<f32>().ok()?;
Some([x, y])
}
pub fn parse_timestamp_ms(timestamp: &str) -> Result<u64, String> {
let timestamp = timestamp.trim();
if timestamp.is_empty() {
return Err("timestamp cannot be empty".to_string());
}
if timestamp.contains(':') {
let parts: Vec<&str> = timestamp.split(':').collect();
if !matches!(parts.len(), 2 | 3) {
return Err("invalid timestamp format. Use HH:MM:SS or seconds".to_string());
}
let mut values = Vec::with_capacity(parts.len());
for part in &parts {
let value: u64 = part
.parse()
.map_err(|_| format!("invalid timestamp component: {part}"))?;
values.push(value);
}
let seconds = if values.len() == 3 {
if values[1] >= 60 || values[2] >= 60 {
return Err("minutes and seconds must be below 60".to_string());
}
values[0]
.checked_mul(3600)
.and_then(|v| v.checked_add(values[1] * 60))
.and_then(|v| v.checked_add(values[2]))
.ok_or_else(|| "timestamp overflows".to_string())?
} else {
if values[1] >= 60 {
return Err("seconds must be below 60".to_string());
}
values[0]
.checked_mul(60)
.and_then(|v| v.checked_add(values[1]))
.ok_or_else(|| "timestamp overflows".to_string())?
};
seconds
.checked_mul(1000)
.ok_or_else(|| "timestamp overflows".to_string())
} else {
let seconds: f64 = timestamp
.parse()
.map_err(|_| format!("invalid timestamp: {timestamp}"))?;
if !seconds.is_finite() || seconds < 0.0 {
return Err("timestamp must be a finite non-negative number".to_string());
}
let millis = (seconds * 1000.0).round();
if millis > u64::MAX as f64 {
return Err("timestamp overflows".to_string());
}
Ok(millis as u64)
}
}
#[derive(Parser, Debug)]
#[command(
name = "wallr",
about = "wayland wallpaper engine with gpu animations and theme pipelines",
version,
override_usage = "wallr [options] <command>",
disable_help_subcommand = true,
disable_version_flag = true,
help_template = "{before-help}{name} {version}\n\n{about}\n\nusage:\n {usage}\n\ncommands:\n{subcommands}\n\noptions:\n{options}{after-help}",
styles = Styles::styled()
.header(AnsiColor::Cyan.on_default().bold())
.usage(AnsiColor::Blue.on_default().bold())
.literal(AnsiColor::Green.on_default().bold())
.placeholder(AnsiColor::Yellow.on_default())
)]
pub struct WallrCli {
#[command(subcommand)]
pub command: Commands,
#[arg(global = true, short = 'c', long, value_name = "path")]
pub config: Option<PathBuf>,
#[arg(global = true, short = 'v', long, action = clap::ArgAction::Count)]
pub verbose: u8,
#[arg(global = true, short = 'q', long)]
pub quiet: bool,
#[arg(short = 'V', long, action = ArgAction::Version)]
pub version: Option<bool>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(alias = "img")]
Set {
#[arg(value_name = "path")]
path: PathBuf,
#[arg(short = 'm', long, value_name = "output")]
monitor: Option<String>,
#[arg(long, value_enum)]
mode: Option<ScalingMode>,
#[arg(long)]
no_theme: bool,
#[arg(long, value_name = "fps")]
fps: Option<u32>,
#[arg(short = 't', long, value_enum)]
theme: Option<ThemeProvider>,
#[command(flatten)]
effect_args: EffectArgs,
},
Doctor,
Config {
#[command(subcommand)]
subcommand: ConfigCommands,
},
Reload,
Monitor {
#[command(subcommand)]
subcommand: MonitorCommands,
},
Daemon {
#[arg(long, value_name = "fps")]
max_fps: Option<u32>,
},
Ipc {
#[command(subcommand)]
subcommand: IpcCommands,
},
Quit,
}
#[derive(Subcommand, Debug)]
pub enum ConfigCommands {
Get {
#[arg(value_name = "key")]
key: String,
},
Set {
#[arg(value_name = "key")]
key: String,
#[arg(value_name = "value")]
value: String,
},
Path,
}
#[derive(Subcommand, Debug)]
pub enum MonitorCommands {
List,
Current,
}
#[derive(Subcommand, Debug)]
pub enum IpcCommands {
Pause {
#[arg(short = 'm', long)]
monitor: Option<String>,
},
Resume {
#[arg(short = 'm', long)]
monitor: Option<String>,
},
Reload,
Stop,
Status,
Info {
#[arg(short = 'm', long)]
monitor: Option<String>,
},
Seek {
#[arg(value_name = "timestamp")]
timestamp: String,
#[arg(short = 'm', long)]
monitor: Option<String>,
},
Blank {
#[arg(short = 'm', long)]
monitor: Option<String>,
#[command(flatten)]
effect_args: EffectArgs,
},
Restore {
#[arg(short = 'm', long)]
monitor: Option<String>,
#[command(flatten)]
effect_args: EffectArgs,
},
}
#[cfg(test)]
mod tests {
use super::parse_timestamp_ms;
#[test]
fn parses_seek_timestamps() {
assert_eq!(parse_timestamp_ms("0").unwrap(), 0);
assert_eq!(parse_timestamp_ms("90").unwrap(), 90_000);
assert_eq!(parse_timestamp_ms("1.5").unwrap(), 1_500);
assert_eq!(parse_timestamp_ms("01:30").unwrap(), 90_000);
assert_eq!(parse_timestamp_ms("01:02:03").unwrap(), 3_723_000);
}
#[test]
fn rejects_invalid_seek_timestamps() {
for value in ["", "-1", "NaN", "inf", "1e300", "1:60", "1:2:60", "1:2:3:4"] {
assert!(parse_timestamp_ms(value).is_err(), "{value} should fail");
}
}
}