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 #[serde(default = "default_max_fps")]
205 pub max_fps: Option<u32>,
206}
207
208impl Default for DaemonConfig {
209 fn default() -> Self {
210 Self {
211 auto_start: true,
212 socket: "$XDG_RUNTIME_DIR/wallr.sock".to_string(),
213 max_fps: Some(60),
214 }
215 }
216}
217
218fn default_max_fps() -> Option<u32> {
219 Some(60)
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct WatchConfig {
224 #[serde(default)]
225 pub enabled: bool,
226 #[serde(default)]
227 pub dir: Option<String>,
228 #[serde(default = "default_debounce")]
229 pub debounce: String,
230}
231
232impl Default for WatchConfig {
233 fn default() -> Self {
234 Self {
235 enabled: false,
236 dir: None,
237 debounce: "500ms".to_string(),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct CacheConfig {
244 #[serde(default = "default_cache_dir")]
245 pub dir: String,
246 #[serde(default = "default_max_size")]
247 pub max_size: String,
248}
249
250impl Default for CacheConfig {
251 fn default() -> Self {
252 Self {
253 dir: "~/.cache/wallr".to_string(),
254 max_size: "512MB".to_string(),
255 }
256 }
257}
258
259fn default_true() -> bool {
260 true
261}
262fn default_mode() -> String {
263 "dark".to_string()
264}
265fn default_scheme() -> String {
266 "scheme-tonal-spot".to_string()
267}
268fn default_duration() -> String {
269 "2000ms".to_string()
270}
271fn default_socket() -> String {
272 "/tmp/wallr.sock".to_string()
273}
274fn default_debounce() -> String {
275 "500ms".to_string()
276}
277fn default_cache_dir() -> String {
278 "~/.cache/wallr".to_string()
279}
280fn default_max_size() -> String {
281 "512MB".to_string()
282}
283
284#[derive(Debug, thiserror::Error)]
285pub enum ConfigError {
286 #[error("failed to read config file: {0}")]
287 ReadError(#[from] std::io::Error),
288 #[error("failed to parse config: {0}")]
289 ParseError(#[from] serde_yaml::Error),
290 #[error("invalid duration format: {0}")]
291 InvalidDuration(String),
292 #[error("invalid size format: {0}")]
293 InvalidSize(String),
294 #[error("invalid config value: {0}")]
295 InvalidValue(String),
296}
297
298pub fn load_config(path: Option<&Path>) -> Result<WallrConfig, ConfigError> {
299 let p = path.map(|p| p.to_path_buf()).unwrap_or_else(config_path);
300 if !p.exists() {
301 return Ok(WallrConfig::default());
302 }
303 let content = std::fs::read_to_string(p)?;
304 let config: WallrConfig = serde_yaml::from_str(&content)?;
305 Ok(config)
306}
307
308pub fn expand_path(path: &str) -> PathBuf {
309 let mut path_str = path.to_string();
310
311 if (path_str.starts_with("~/") || path_str == "~")
312 && let Ok(home) = std::env::var("HOME")
313 {
314 if path_str == "~" {
315 path_str = home;
316 } else {
317 path_str = path_str.replacen("~", &home, 1);
318 }
319 }
320
321 let mut expanded = String::new();
322 let mut chars = path_str.chars().peekable();
323
324 while let Some(c) = chars.next() {
325 if c == '$' {
326 let mut env_var = String::new();
327 while let Some(&next_c) = chars.peek() {
328 if next_c.is_alphanumeric() || next_c == '_' {
329 env_var.push(next_c);
330 chars.next();
331 } else {
332 break;
333 }
334 }
335 if let Ok(val) = std::env::var(&env_var) {
336 expanded.push_str(&val);
337 }
338 } else {
339 expanded.push(c);
340 }
341 }
342
343 PathBuf::from(expanded)
344}
345
346pub fn config_path() -> PathBuf {
347 if let Ok(path) = std::env::var("WALLR_CONFIG") {
348 return PathBuf::from(path);
349 }
350
351 if let Ok(config_home) = std::env::var("XDG_CONFIG_HOME") {
352 return PathBuf::from(config_home).join("wallr/config.yaml");
353 }
354
355 if let Ok(home) = std::env::var("HOME") {
356 return PathBuf::from(home).join(".config/wallr/config.yaml");
357 }
358
359 PathBuf::from("/tmp/wallr/config.yaml")
360}
361
362pub fn parse_duration(s: &str) -> Result<std::time::Duration, ConfigError> {
363 let s = s.trim();
364 if let Some(ms) = s.strip_suffix("ms") {
365 let val: u64 = ms
366 .parse()
367 .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
368 Ok(std::time::Duration::from_millis(val))
369 } else if let Some(sec) = s.strip_suffix('s') {
370 let val: f64 = sec
371 .parse()
372 .map_err(|_| ConfigError::InvalidDuration(s.to_string()))?;
373 if !val.is_finite() || val < 0.0 {
374 return Err(ConfigError::InvalidDuration(s.to_string()));
375 }
376 Ok(std::time::Duration::from_secs_f64(val))
377 } else if let Ok(val) = s.parse::<u64>() {
378 Ok(std::time::Duration::from_millis(val))
379 } else {
380 Err(ConfigError::InvalidDuration(s.to_string()))
381 }
382}
383
384pub fn parse_size(s: &str) -> Result<u64, ConfigError> {
385 let s = s.trim().to_uppercase();
386 if let Some(num) = s.strip_suffix("GB") {
387 let val: u64 = num
388 .parse()
389 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
390 Ok(val * 1024 * 1024 * 1024)
391 } else if let Some(num) = s.strip_suffix("MB") {
392 let val: u64 = num
393 .parse()
394 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
395 Ok(val * 1024 * 1024)
396 } else if let Some(num) = s.strip_suffix("KB") {
397 let val: u64 = num
398 .parse()
399 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
400 Ok(val * 1024)
401 } else if let Some(num) = s.strip_suffix('B') {
402 let val: u64 = num
403 .parse()
404 .map_err(|_| ConfigError::InvalidSize(s.to_string()))?;
405 Ok(val)
406 } else if let Ok(val) = s.parse::<u64>() {
407 Ok(val)
408 } else {
409 Err(ConfigError::InvalidSize(s.to_string()))
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_default_config() {
419 let cfg = WallrConfig::default();
420 assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
421 assert_eq!(cfg.animation.duration, "2000ms");
422 assert!(cfg.matugen.enabled);
423 }
424
425 #[test]
426 fn test_load_config_missing_file() {
427 let path = Path::new("/nonexistent/config.yaml");
428 let cfg = load_config(Some(path)).unwrap();
429 assert_eq!(cfg.wallpaper.mode, ScalingMode::Fill);
430 }
431
432 #[test]
433 fn test_parse_duration_ms() {
434 let dur = parse_duration("500ms").unwrap();
435 assert_eq!(dur.as_millis(), 500);
436 }
437
438 #[test]
439 fn test_parse_duration_s() {
440 let dur = parse_duration("2s").unwrap();
441 assert_eq!(dur.as_secs(), 2);
442 }
443
444 #[test]
445 fn test_parse_size_mb() {
446 let bytes = parse_size("512MB").unwrap();
447 assert_eq!(bytes, 512 * 1024 * 1024);
448 }
449
450 #[test]
451 fn test_parse_size_gb() {
452 let bytes = parse_size("2GB").unwrap();
453 assert_eq!(bytes, 2 * 1024 * 1024 * 1024);
454 }
455
456 #[test]
457 fn test_expand_path_tilde() {
458 let expanded = expand_path("~/test.jpg");
459 assert!(!expanded.to_string_lossy().starts_with('~'));
460 }
461
462 #[test]
463 fn test_expand_path_env() {
464 unsafe {
465 std::env::set_var("TEST_VAR", "my_folder");
466 }
467 let expanded = expand_path("/tmp/$TEST_VAR/file.png");
468 assert!(expanded.to_string_lossy().contains("my_folder"));
469 }
470
471 #[test]
472 fn test_roundtrip_serialize() {
473 let cfg = WallrConfig::default();
474 let yaml = serde_yaml::to_string(&cfg).unwrap();
475 let parsed: WallrConfig = serde_yaml::from_str(&yaml).unwrap();
476 assert_eq!(parsed.animation.duration, cfg.animation.duration);
477 }
478}