Skip to main content

focus_tracker_core/
config.rs

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    /// Creates a new icon configuration using the builder pattern.
61    ///
62    /// # Example
63    ///
64    /// ```
65    /// use focus_tracker_core::IconConfig;
66    ///
67    /// // Default config (no custom size, Lanczos3 filter)
68    /// let config = IconConfig::builder().build();
69    ///
70    /// // Custom 64×64 icon size
71    /// let config = IconConfig::builder()
72    ///     .size(64)
73    ///     .unwrap()
74    ///     .build();
75    /// ```
76    #[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    /// Ignore rules applied when running on Linux.
103    ///
104    /// Consulted only by the Linux tracker; ignored on other platforms.
105    /// See [`IgnoreRule`] for the matcher semantics.
106    pub linux_ignore_rules: IgnoreRules,
107    /// Ignore rules applied when running on macOS.
108    ///
109    /// Consulted only by the macOS tracker; ignored on other platforms.
110    /// See [`IgnoreRule`] for the matcher semantics.
111    pub macos_ignore_rules: IgnoreRules,
112    /// Ignore rules applied when running on Windows.
113    ///
114    /// Consulted only by the Windows tracker; ignored on other platforms.
115    /// See [`IgnoreRule`] for the matcher semantics.
116    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    /// Returns the ignore rules that apply to the current build target.
134    #[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            // Fall back to a stable reference so other platforms compile.
151            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    /// Creates a new focus tracker configuration using the builder pattern.
160    ///
161    /// # Example
162    ///
163    /// ```
164    /// use focus_tracker_core::{FocusTrackerConfig, IconConfig, IgnoreRule, WindowTitleMatch};
165    /// use std::time::Duration;
166    ///
167    /// let config = FocusTrackerConfig::builder()
168    ///     .poll_interval(Duration::from_millis(50))
169    ///     .unwrap()
170    ///     .icon(IconConfig::builder().size(64).unwrap().build())
171    ///     .windows_ignore_rules([
172    ///         IgnoreRule::builder()
173    ///             .process_name("whatever")
174    ///             .window_title(WindowTitleMatch::Missing)
175    ///             .build(),
176    ///     ])
177    ///     .build();
178    /// ```
179    #[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        // The motivating case: suppress "whatever" only when it has no
407        // title; keep events for titled instances.
408        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}