1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct WallrConfig {
6 #[serde(default)]
7 pub wallpaper: WallpaperConfig,
8 #[serde(default)]
9 pub animation: AnimationConfig,
10 #[serde(default)]
11 pub theme: ThemeConfig,
12 #[serde(default)]
13 pub matugen: MatugenConfig,
14 #[serde(default)]
15 pub hooks: HooksConfig,
16 #[serde(default)]
17 pub reload: Vec<String>,
18 #[serde(default)]
19 pub daemon: DaemonConfig,
20 #[serde(default)]
21 pub watch: WatchConfig,
22 #[serde(default)]
23 pub cache: CacheConfig,
24 #[serde(default)]
25 pub plugins: PluginsConfig,
26 #[serde(default)]
27 pub video: VideoConfig,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct PluginsConfig {
32 #[serde(default)]
33 pub matugen: PluginConfig,
34 #[serde(default)]
35 pub pywal: PluginConfig,
36 #[serde(default)]
37 pub wallust: PluginConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, Default)]
41pub struct PluginConfig {
42 #[serde(default)]
43 pub enabled: bool,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct WallpaperConfig {
48 #[serde(default)]
49 pub default: Option<String>,
50 #[serde(default)]
51 pub mode: ScalingMode,
52 #[serde(default)]
53 pub monitors: Vec<MonitorConfig>,
54 #[serde(default = "default_true")]
55 pub loop_video: bool,
56 #[serde(default = "default_true")]
57 pub mute: bool,
58}
59
60impl Default for WallpaperConfig {
61 fn default() -> Self {
62 Self {
63 default: None,
64 mode: ScalingMode::Fill,
65 monitors: vec![],
66 loop_video: true,
67 mute: true,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct VideoConfig {
74 #[serde(default = "default_hw_decode")]
75 pub hw_decode: String,
76 #[serde(default)]
77 pub preferred_gpu: crate::video::GpuSelection,
78 #[serde(default = "default_preload_frames")]
79 pub preload_frames: usize,
80}
81
82impl Default for VideoConfig {
83 fn default() -> Self {
84 Self {
85 hw_decode: default_hw_decode(),
86 preferred_gpu: crate::video::GpuSelection::Auto,
87 preload_frames: default_preload_frames(),
88 }
89 }
90}
91
92fn default_hw_decode() -> String {
93 "auto".to_string()
94}
95
96fn default_preload_frames() -> usize {
97 2
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct MonitorConfig {
102 pub name: String,
103 #[serde(default)]
104 pub file: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, clap::ValueEnum, Default, PartialEq)]
108#[serde(rename_all = "snake_case")]
109pub enum ScalingMode {
110 #[default]
111 Fill,
112 Fit,
113 Stretch,
114 Center,
115 Tile,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct AnimationConfig {
120 #[serde(rename = "use", default)]
121 pub r#use: Option<String>,
122 #[serde(default = "default_duration")]
123 pub duration: String,
124}
125
126impl Default for AnimationConfig {
127 fn default() -> Self {
128 Self {
129 r#use: None,
130 duration: "2000ms".to_string(),
131 }
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ThemeConfig {
137 #[serde(default)]
138 pub provider: ThemeProvider,
139}
140
141impl Default for ThemeConfig {
142 fn default() -> Self {
143 Self {
144 provider: ThemeProvider::Matugen,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, clap::ValueEnum, Default, PartialEq)]
150#[serde(rename_all = "snake_case")]
151pub enum ThemeProvider {
152 Matugen,
153 Wallust,
154 Pywal,
155 #[default]
156 None,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct MatugenConfig {
161 #[serde(default = "default_true")]
162 pub enabled: bool,
163 #[serde(default = "default_mode")]
164 pub mode: String,
165 #[serde(default = "default_scheme")]
166 pub scheme: String,
167 #[serde(default)]
168 pub contrast: i32,
169 #[serde(default)]
170 pub wait: bool,
171 #[serde(default)]
172 pub args: Vec<String>,
173}
174
175impl Default for MatugenConfig {
176 fn default() -> Self {
177 Self {
178 enabled: true,
179 mode: "dark".to_string(),
180 scheme: "scheme-tonal-spot".to_string(),
181 contrast: 0,
182 wait: true,
183 args: vec![],
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189pub struct HooksConfig {
190 #[serde(default)]
191 pub before: Vec<String>,
192 #[serde(default)]
193 pub after: Vec<String>,
194 #[serde(default)]
195 pub error: Vec<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DaemonConfig {
200 #[serde(default)]
201 pub auto_start: bool,
202 #[serde(default = "default_socket")]
203 pub socket: String,
204}
205
206impl Default for DaemonConfig {
207 fn default() -> Self {
208 Self {
209 auto_start: true,
210 socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct WatchConfig {
217 #[serde(default)]
218 pub enabled: bool,
219 #[serde(default)]
220 pub dir: Option<String>,
221 #[serde(default = "default_debounce")]
222 pub debounce: String,
223}
224
225impl Default for WatchConfig {
226 fn default() -> Self {
227 Self {
228 enabled: false,
229 dir: None,
230 debounce: "500ms".to_string(),
231 }
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct CacheConfig {
237 #[serde(default = "default_cache_dir")]
238 pub dir: String,
239 #[serde(default = "default_max_size")]
240 pub max_size: String,
241}
242
243impl Default for CacheConfig {
244 fn default() -> Self {
245 Self {
246 dir: "~/.cache/wallr".to_string(),
247 max_size: "512MB".to_string(),
248 }
249 }
250}
251
252fn default_true() -> bool {
253 true
254}
255fn default_mode() -> String {
256 "dark".to_string()
257}
258fn default_scheme() -> String {
259 "scheme-tonal-spot".to_string()
260}
261fn default_duration() -> String {
262 "2000ms".to_string()
263}
264fn default_socket() -> String {
265 "/tmp/wallr.sock".to_string()
266}
267fn default_debounce() -> String {
268 "500ms".to_string()
269}
270fn default_cache_dir() -> String {
271 "~/.cache/wallr".to_string()
272}
273fn default_max_size() -> String {
274 "512MB".to_string()
275}
276
277#[derive(Debug, thiserror::Error)]
278pub enum ConfigError {
279 #[error("failed to read config file: {0}")]
280 ReadError(#[from] std::io::Error),
281 #[error("failed to parse config: {0}")]
282 ParseError(#[from] serde_yaml::Error),
283 #[error("invalid duration format: {0}")]
284 InvalidDuration(String),
285 #[error("invalid size format: {0}")]
286 InvalidSize(String),
287 #[error("invalid config value: {0}")]
288 InvalidValue(String),
289}
290
291pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
292 let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
293 if !p.exists() {
294 return Ok(WallrConfig::default());
295 }
296 let content = std::fs::read_to_string(p)?;
297 let config: WallrConfig = serde_yaml::from_str(&content)?;
298 Ok(config)
299}
300
301pub fn expand_path(path: &str) -> PathBuf {
302 let mut path_str = path.to_string();
303
304 if (path_str.starts_with("~/") || path_str == "~")
305 && let Ok(home) = std::env::var("HOME")
306 {
307 if path_str == "~" {
308 path_str = home;
309 } else {
310 path_str = path_str.replacen("~", &home, 1);
311 }
312 }
313
314 let mut expanded = String::new();
315 let mut chars = path_str.chars().peekable();
316
317 while let Some(c) = chars.next() {
318 if c == '$' {
319 let mut env_var = String::new();
320 while let Some(&next_c) = chars.peek() {
321 if next_c.is_alphanumeric() || next_c == '_' {
322 env_var.push(next_c);
323 chars.next();
324 } else {
325 break;
326 }
327 }
328 if let Ok(val) = std::env::var(&env_var) {
329 expanded.push_str(&val);
330 }
331 } else {
332 expanded.push(c);
333 }
334 }
335
336 PathBuf::from(expanded)
337}
338
339pub fn config_path() -> PathBuf {
340 if let Ok(path) = std::env::var("WALLR_CONFIG") {
341 return PathBuf::from(path);
342 }
343
344 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
345 return PathBuf::from(config_home).join("wallr/config.yaml");
346 }
347
348 if let Ok(home) = std::env::var("HOME") {
349 return PathBuf::from(home).join(".config/wallr/config.yaml");
350 }
351
352 PathBuf::from("/tmp/wallr/config.yaml")
353}
354
355pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
356 let s = s.trim();
357 if let Some(ms) = s.strip_suffix("ms") {
358 let val: u64 = ms
359 .parse()
360 .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
361 Ok(std::time::Duration::from_millis(val))
362 } else if let Some(sec) = s.strip_suffix('s') {
363 let val: f64 = sec
364 .parse()
365 .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
366 if !val.is_finite() || val < 0.0 {
367 return Err(ConfigError::InvalidDuration(s.to_string()));
368 }
369 Ok(std::time::Duration::from_secs_f64(val))
370 } else if let Ok(val) = s.parse::<u64>() {
371 Ok(std::time::Duration::from_millis(val))
372 } else {
373 Err(ConfigError::InvalidDuration(s.to_string()))
374 }
375}
376
377pub fn parse_size(s: &str) -> Result<u64, ConfigError> {
378 let s = s.trim().to_uppercase();
379 if let Some(num) = s.strip_suffix("GB") {
380 let val: u64 = num
381 .parse()
382 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
383 Ok(val * 1024 * 1024 * 1024)
384 } else if let Some(num) = s.strip_suffix("MB") {
385 let val: u64 = num
386 .parse()
387 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
388 Ok(val * 1024 * 1024)
389 } else if let Some(num) = s.strip_suffix("KB") {
390 let val: u64 = num
391 .parse()
392 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
393 Ok(val * 1024)
394 } else if let Some(num) = s.strip_suffix('B') {
395 let val: u64 = num
396 .parse()
397 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
398 Ok(val)
399 } else if let Ok(val) = s.parse::<u64>() {
400 Ok(val)
401 } else {
402 Err(ConfigError::InvalidSize(s.to_string()))
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn test_default_config() {
412 let cfg = WallrConfig::default();
413 assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
414 assert_eq!(cfg.animation.duration, "2000ms");
415 assert!(cfg.matugen.enabled);
416 }
417
418 #[test]
419 fn test_load_config_missing_file() {
420 let path = Path::new("/nonexistent/config.yaml");
421 let cfg = load_config(Some(path)).unwrap();
422 assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
423 }
424
425 #[test]
426 fn test_parse_duration_ms() {
427 let dur = parse_duration("500ms").unwrap();
428 assert_eq!(dur.as_millis(), 500);
429 }
430
431 #[test]
432 fn test_parse_duration_s() {
433 let dur = parse_duration("2s").unwrap();
434 assert_eq!(dur.as_secs(), 2);
435 }
436
437 #[test]
438 fn test_parse_size_mb() {
439 let bytes = parse_size("512MB").unwrap();
440 assert_eq!(bytes, 512 * 1024 * 1024);
441 }
442
443 #[test]
444 fn test_parse_size_gb() {
445 let bytes = parse_size("2GB").unwrap();
446 assert_eq!(bytes, 2 * 1024 * 1024 * 1024);
447 }
448
449 #[test]
450 fn test_expand_path_tilde() {
451 let expanded = expand_path("~/test.jpg");
452 assert!(!expanded.to_string_lossy().starts_with('~'));
453 }
454
455 #[test]
456 fn test_expand_path_env() {
457 unsafe {
458 std::env::set_var("TEST_VAR", "my_folder");
459 }
460 let expanded = expand_path("/tmp/$TEST_VAR/file.png");
461 assert!(expanded.to_string_lossy().contains("my_folder"));
462 }
463
464 #[test]
465 fn test_roundtrip_serialize() {
466 let cfg = WallrConfig::default();
467 let yaml = serde_yaml::to_string(&cfg).unwrap();
468 let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
469 assert_eq!(parsed.animation.duration, cfg.animation.duration);
470 }
471}