1use bon::bon;
2
3use crate::{FocusTrackerError, FocusTrackerResult, IgnoreRule, IgnoreRules};
4use std::time::Duration;
5
6fn validate_icon_size(size: u32) -> FocusTrackerResult<u32> {
7 if size == 0 {
8 return Err(FocusTrackerError::InvalidConfig {
9 reason: "icon size cannot be zero".into(),
10 });
11 }
12 if size > 512 {
13 return Err(FocusTrackerError::InvalidConfig {
14 reason: "icon size cannot be greater than 512 pixels".into(),
15 });
16 }
17 Ok(size)
18}
19
20fn validate_poll_interval(interval: Duration) -> FocusTrackerResult<Duration> {
21 if interval.is_zero() {
22 return Err(FocusTrackerError::InvalidConfig {
23 reason: "poll interval cannot be zero".into(),
24 });
25 }
26 if interval > Duration::from_secs(10) {
27 return Err(FocusTrackerError::InvalidConfig {
28 reason: "poll interval cannot be greater than 10 seconds".into(),
29 });
30 }
31 Ok(interval)
32}
33
34fn validate_icon_cache_capacity(capacity: usize) -> FocusTrackerResult<usize> {
35 if capacity == 0 {
36 return Err(FocusTrackerError::InvalidConfig {
37 reason: "icon cache capacity cannot be zero".into(),
38 });
39 }
40 Ok(capacity)
41}
42
43#[derive(Debug, Clone)]
44pub struct IconConfig {
45 pub size: Option<u32>,
46 pub filter_type: image::imageops::FilterType,
47}
48
49impl Default for IconConfig {
50 fn default() -> Self {
51 Self {
52 size: None,
53 filter_type: image::imageops::FilterType::Lanczos3,
54 }
55 }
56}
57
58#[bon]
59impl IconConfig {
60 #[builder]
77 pub fn new(
78 #[builder(with = |size: u32| -> Result<_, FocusTrackerError> {
79 validate_icon_size(size)
80 })]
81 size: Option<u32>,
82
83 #[builder(default = image::imageops::FilterType::Lanczos3)]
84 filter_type: image::imageops::FilterType,
85 ) -> Self {
86 Self { size, filter_type }
87 }
88}
89
90impl IconConfig {
91 #[must_use]
92 pub fn get_size_or_default(&self) -> u32 {
93 self.size.unwrap_or(128)
94 }
95}
96
97#[derive(Debug, Clone)]
98pub struct FocusTrackerConfig {
99 pub poll_interval: Duration,
100 pub icon: IconConfig,
101 pub icon_cache_capacity: usize,
102 pub linux_ignore_rules: IgnoreRules,
107 pub macos_ignore_rules: IgnoreRules,
112 pub windows_ignore_rules: IgnoreRules,
117}
118
119impl Default for FocusTrackerConfig {
120 fn default() -> Self {
121 Self {
122 poll_interval: Duration::from_millis(100),
123 icon: IconConfig::default(),
124 icon_cache_capacity: 64,
125 linux_ignore_rules: IgnoreRules::default(),
126 macos_ignore_rules: IgnoreRules::default(),
127 windows_ignore_rules: IgnoreRules::default(),
128 }
129 }
130}
131
132impl FocusTrackerConfig {
133 #[must_use]
135 pub fn ignore_rules_for_current_platform(&self) -> &IgnoreRules {
136 #[cfg(target_os = "linux")]
137 {
138 &self.linux_ignore_rules
139 }
140 #[cfg(target_os = "macos")]
141 {
142 &self.macos_ignore_rules
143 }
144 #[cfg(target_os = "windows")]
145 {
146 &self.windows_ignore_rules
147 }
148 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
149 {
150 static EMPTY: std::sync::OnceLock<IgnoreRules> = std::sync::OnceLock::new();
152 EMPTY.get_or_init(IgnoreRules::default)
153 }
154 }
155}
156
157#[bon]
158impl FocusTrackerConfig {
159 #[builder]
180 pub fn new(
181 #[builder(
182 default = Duration::from_millis(100),
183 with = |interval: Duration| -> Result<_, FocusTrackerError> {
184 validate_poll_interval(interval)
185 },
186 )]
187 poll_interval: Duration,
188 #[builder(default)] icon: IconConfig,
189 #[builder(
190 default = 64,
191 with = |capacity: usize| -> Result<_, FocusTrackerError> {
192 validate_icon_cache_capacity(capacity)
193 },
194 )]
195 icon_cache_capacity: usize,
196 #[builder(
197 default,
198 with = |rules: impl IntoIterator<Item = IgnoreRule>| IgnoreRules::new(rules),
199 )]
200 linux_ignore_rules: IgnoreRules,
201 #[builder(
202 default,
203 with = |rules: impl IntoIterator<Item = IgnoreRule>| IgnoreRules::new(rules),
204 )]
205 macos_ignore_rules: IgnoreRules,
206 #[builder(
207 default,
208 with = |rules: impl IntoIterator<Item = IgnoreRule>| IgnoreRules::new(rules),
209 )]
210 windows_ignore_rules: IgnoreRules,
211 ) -> Self {
212 Self {
213 poll_interval,
214 icon,
215 icon_cache_capacity,
216 linux_ignore_rules,
217 macos_ignore_rules,
218 windows_ignore_rules,
219 }
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn default_icon_config() {
229 let config = IconConfig::default();
230 assert_eq!(config.size, None);
231 assert_eq!(config.get_size_or_default(), 128);
232 }
233
234 #[test]
235 fn icon_builder_defaults() {
236 let config = IconConfig::builder().build();
237 assert_eq!(config.size, None);
238 assert_eq!(config.get_size_or_default(), 128);
239 }
240
241 #[test]
242 fn icon_builder_with_size() {
243 let config = IconConfig::builder().size(256).unwrap().build();
244 assert_eq!(config.size, Some(256));
245 assert_eq!(config.get_size_or_default(), 256);
246 }
247
248 #[test]
249 fn icon_builder_max_size() {
250 let config = IconConfig::builder().size(512).unwrap().build();
251 assert_eq!(config.size, Some(512));
252 }
253
254 #[test]
255 fn icon_builder_min_size() {
256 let config = IconConfig::builder().size(1).unwrap().build();
257 assert_eq!(config.size, Some(1));
258 }
259
260 #[test]
261 fn icon_builder_zero_size_errors() {
262 assert!(IconConfig::builder().size(0).is_err());
263 }
264
265 #[test]
266 fn icon_builder_oversized_errors() {
267 assert!(IconConfig::builder().size(513).is_err());
268 assert!(IconConfig::builder().size(1024).is_err());
269 }
270
271 #[test]
272 fn icon_builder_custom_filter() {
273 let config = IconConfig::builder()
274 .filter_type(image::imageops::FilterType::Nearest)
275 .build();
276 assert!(matches!(
277 config.filter_type,
278 image::imageops::FilterType::Nearest
279 ));
280 }
281
282 #[test]
283 fn default_config() {
284 let config = FocusTrackerConfig::default();
285 assert_eq!(config.poll_interval, Duration::from_millis(100));
286 assert_eq!(config.icon.size, None);
287 assert_eq!(config.icon_cache_capacity, 64);
288 }
289
290 #[test]
291 fn config_builder_defaults() {
292 let config = FocusTrackerConfig::builder().build();
293 assert_eq!(config.poll_interval, Duration::from_millis(100));
294 assert_eq!(config.icon.size, None);
295 assert_eq!(config.icon_cache_capacity, 64);
296 }
297
298 #[test]
299 fn config_builder_icon_cache_capacity() {
300 let config = FocusTrackerConfig::builder()
301 .icon_cache_capacity(128)
302 .unwrap()
303 .build();
304 assert_eq!(config.icon_cache_capacity, 128);
305 }
306
307 #[test]
308 fn config_builder_zero_cache_capacity_errors() {
309 assert!(
310 FocusTrackerConfig::builder()
311 .icon_cache_capacity(0)
312 .is_err()
313 );
314 }
315
316 #[test]
317 fn config_builder_poll_interval() {
318 let config = FocusTrackerConfig::builder()
319 .poll_interval(Duration::from_millis(250))
320 .unwrap()
321 .build();
322 assert_eq!(config.poll_interval, Duration::from_millis(250));
323 }
324
325 #[test]
326 fn config_builder_max_interval() {
327 let config = FocusTrackerConfig::builder()
328 .poll_interval(Duration::from_secs(10))
329 .unwrap()
330 .build();
331 assert_eq!(config.poll_interval, Duration::from_secs(10));
332 }
333
334 #[test]
335 fn config_builder_zero_interval_errors() {
336 assert!(
337 FocusTrackerConfig::builder()
338 .poll_interval(Duration::ZERO)
339 .is_err()
340 );
341 }
342
343 #[test]
344 fn config_builder_large_interval_errors() {
345 assert!(
346 FocusTrackerConfig::builder()
347 .poll_interval(Duration::from_secs(11))
348 .is_err()
349 );
350 }
351
352 #[test]
353 fn config_builder_with_icon() {
354 let icon = IconConfig::builder().size(64).unwrap().build();
355 let config = FocusTrackerConfig::builder().icon(icon).build();
356 assert_eq!(config.icon.size, Some(64));
357 }
358
359 #[test]
360 fn config_builder_full() {
361 let config = FocusTrackerConfig::builder()
362 .poll_interval(Duration::from_millis(50))
363 .unwrap()
364 .icon(IconConfig::builder().size(64).unwrap().build())
365 .build();
366
367 assert_eq!(config.poll_interval, Duration::from_millis(50));
368 assert_eq!(config.icon.size, Some(64));
369 assert_eq!(config.icon.get_size_or_default(), 64);
370 }
371
372 #[test]
373 fn config_default_ignore_rules_are_empty() {
374 let config = FocusTrackerConfig::default();
375 assert!(config.linux_ignore_rules.is_empty());
376 assert!(config.macos_ignore_rules.is_empty());
377 assert!(config.windows_ignore_rules.is_empty());
378 }
379
380 #[test]
381 fn config_builder_per_platform_ignore_rules() {
382 let config = FocusTrackerConfig::builder()
383 .linux_ignore_rules([IgnoreRule::builder().process_name("firefox").build()])
384 .macos_ignore_rules([IgnoreRule::builder().process_name("Firefox").build()])
385 .windows_ignore_rules([
386 IgnoreRule::builder().process_name("firefox.exe").build(),
387 IgnoreRule::builder().process_name("chrome.exe").build(),
388 ])
389 .build();
390
391 assert!(config.linux_ignore_rules.matches("firefox", None));
392 assert!(!config.linux_ignore_rules.matches("firefox.exe", None));
393
394 assert!(config.macos_ignore_rules.matches("Firefox", None));
395 assert!(!config.macos_ignore_rules.matches("firefox", None));
396
397 assert_eq!(config.windows_ignore_rules.len(), 2);
398 assert!(config.windows_ignore_rules.matches("firefox.exe", None));
399 assert!(config.windows_ignore_rules.matches("chrome.exe", None));
400 }
401
402 #[test]
403 fn config_builder_supports_title_aware_rules() {
404 use crate::WindowTitleMatch;
405
406 let config = FocusTrackerConfig::builder()
409 .windows_ignore_rules([IgnoreRule::builder()
410 .process_name("whatever")
411 .window_title(WindowTitleMatch::Missing)
412 .build()])
413 .build();
414
415 assert!(config.windows_ignore_rules.matches("whatever", None));
416 assert!(config.windows_ignore_rules.matches("whatever", Some("")));
417 assert!(!config.windows_ignore_rules.matches("whatever", Some("Doc")));
418 assert!(!config.windows_ignore_rules.matches("other", None));
419 }
420
421 #[test]
422 fn config_current_platform_selector_matches_target() {
423 let config = FocusTrackerConfig::builder()
424 .linux_ignore_rules([IgnoreRule::builder().process_name("lin").build()])
425 .macos_ignore_rules([IgnoreRule::builder().process_name("mac").build()])
426 .windows_ignore_rules([IgnoreRule::builder().process_name("win").build()])
427 .build();
428
429 let current = config.ignore_rules_for_current_platform();
430 #[cfg(target_os = "linux")]
431 assert!(current.matches("lin", None));
432 #[cfg(target_os = "macos")]
433 assert!(current.matches("mac", None));
434 #[cfg(target_os = "windows")]
435 assert!(current.matches("win", None));
436 }
437}