virtual_desktop_manager/
window_filter.rs

1//! Rules or "filters" that specify actions this program should take on all
2//! currently open windows.
3
4#[cfg(feature = "persist_filters_xml")]
5mod xml_format {
6    //! Serialize or deserialize window filters as XML in the format expected by
7    //! the C# program <https://github.com/Lej77/VirtualDesktopManager>.
8    //!
9    //! The types were initially generated by
10    //! <https://github.com/Thomblin/xml_schema_generator>.
11    use serde::{Deserialize, Serialize};
12    use std::borrow::Cow;
13
14    #[derive(Serialize, Deserialize)]
15    pub struct SaveFile {
16        #[serde(rename = "@xmlns:xsi", default)]
17        pub xmlns_xsi: Cow<'static, str>,
18        #[serde(rename = "@xmlns:xsd", default)]
19        pub xmlns_xsd: Cow<'static, str>,
20        pub filters: Filters,
21    }
22    impl Default for SaveFile {
23        fn default() -> Self {
24            Self {
25                xmlns_xsi: Cow::Borrowed("http://www.w3.org/2001/XMLSchema-instance"),
26                xmlns_xsd: Cow::Borrowed("http://www.w3.org/2001/XMLSchema"),
27                filters: Filters::default(),
28            }
29        }
30    }
31
32    #[derive(Default, Serialize, Deserialize)]
33    pub struct Filters {
34        #[serde(rename = "SaveData", default)]
35        pub save_data: Vec<SaveData>,
36    }
37
38    #[derive(Serialize, Deserialize)]
39    pub struct SaveData {
40        pub data: Data,
41    }
42
43    fn minus_one() -> i64 {
44        -1
45    }
46    /// Handle xml deserializing for empty elements (these are interpreted as
47    /// empty strings by `quick-xml`). This returns `-1` for missing elements.
48    ///
49    /// See <https://github.com/tafia/quick-xml/issues/497> for more info about
50    /// why this is needed.
51    fn deserialize_missing<'de, D>(de: D) -> Result<i64, D::Error>
52    where
53        D: serde::Deserializer<'de>,
54    {
55        let text = <&str>::deserialize(de)?;
56        if text.is_empty() {
57            Ok(-1)
58        } else {
59            text.parse::<i64>().map_err(serde::de::Error::custom)
60        }
61    }
62
63    #[derive(Serialize, Deserialize)]
64    pub struct Data {
65        /// `-1` when disabled, zero-based index
66        #[serde(
67            rename = "indexLowerBound",
68            default = "minus_one",
69            deserialize_with = "deserialize_missing"
70        )]
71        pub index_lower_bound: i64,
72        /// `-1` when disabled, zero-based index
73        #[serde(
74            rename = "indexUpperBound",
75            default = "minus_one",
76            deserialize_with = "deserialize_missing"
77        )]
78        pub index_upper_bound: i64,
79        /// `-1` when disabled, zero-based index
80        #[serde(
81            rename = "desktopLowerBound",
82            default = "minus_one",
83            deserialize_with = "deserialize_missing"
84        )]
85        pub desktop_lower_bound: i64,
86        /// `-1` when disabled, zero-based index
87        #[serde(
88            rename = "desktopUpperBound",
89            default = "minus_one",
90            deserialize_with = "deserialize_missing"
91        )]
92        pub desktop_upper_bound: i64,
93        #[serde(default)]
94        pub title: Title,
95        #[serde(default)]
96        pub process: Process,
97        #[serde(rename = "isMainProcessWindow")]
98        pub is_main_process_window: bool,
99        #[serde(rename = "checkIfMainWindow", default)]
100        pub check_if_main_window: bool,
101        /// Can be omitted in later versions. `-1` when disabled, zero-based index
102        #[serde(
103            rename = "desktopTarget",
104            default = "minus_one",
105            deserialize_with = "deserialize_missing"
106        )]
107        pub desktop_target: i64,
108        #[serde(rename = "desktopTargetAdv", default)]
109        pub desktop_target_adv: Option<DesktopTargetAdv>,
110    }
111
112    #[derive(Default, Serialize, Deserialize)]
113    pub struct Title {
114        #[serde(default)]
115        pub string: Vec<String>,
116    }
117
118    #[derive(Default, Serialize, Deserialize)]
119    pub struct Process {
120        #[serde(default)]
121        pub string: Vec<String>,
122    }
123
124    #[derive(Serialize, Deserialize)]
125    pub struct DesktopTargetAdv {
126        #[serde(rename = "allowUnpin", default)]
127        pub allow_unpin: bool,
128        #[serde(rename = "shouldPin", default)]
129        pub should_pin: bool,
130        /// `-1` when disabled (not moving to target desktop)
131        #[serde(
132            rename = "targetDesktopIndex",
133            default = "minus_one",
134            deserialize_with = "deserialize_missing"
135        )]
136        pub target_desktop_index: i64,
137    }
138}
139
140#[cfg(feature = "persist_filters")]
141use serde::{Deserialize, Serialize};
142
143use std::{fmt, sync::Arc};
144
145use crate::window_info::{VirtualDesktopInfo, WindowInfo};
146
147#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
148#[cfg_attr(feature = "persist_filters", derive(Serialize, Deserialize))]
149pub struct IntegerRange {
150    pub lower_bound: Option<i64>,
151    pub upper_bound: Option<i64>,
152}
153impl IntegerRange {
154    pub fn contains(&self, value: i64) -> bool {
155        match (self.lower_bound, self.upper_bound) {
156            (None, None) => true,
157            (None, Some(upper)) => value <= upper,
158            (Some(lower), None) => lower <= value,
159            (Some(lower), Some(upper)) => lower <= value && value <= upper,
160        }
161    }
162    /// Increment both lower and upper bounds in order to convert from
163    /// zero-based to one-based indexes.
164    pub fn one_based_indexes(self) -> Self {
165        Self {
166            lower_bound: self.lower_bound.map(|v| v.saturating_add(1)),
167            upper_bound: self.upper_bound.map(|v| v.saturating_add(1)),
168        }
169    }
170}
171/// Used to display range in list column.
172impl fmt::Display for IntegerRange {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match (self.lower_bound, self.upper_bound) {
175            (None, None) => Ok(()),
176            (None, Some(upper)) => write!(f, "- {upper}"),
177            (Some(lower), None) => write!(f, "{lower} -"),
178            (Some(lower), Some(upper)) => write!(f, "{lower} - {upper}"),
179        }
180    }
181}
182
183/// Represents a pattern that can be matched against some text.
184#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
185#[cfg_attr(feature = "persist_filters", derive(Serialize, Deserialize))]
186#[cfg_attr(feature = "persist_filters", serde(transparent))]
187pub struct TextPattern {
188    pattern: Arc<str>,
189}
190impl TextPattern {
191    pub fn new(pattern: Arc<str>) -> Self {
192        Self { pattern }
193    }
194    /// Check if a text matches a "glob" pattern that allows anything at
195    /// newlines.
196    pub fn check_newline_glob(&self, mut text: &str) -> bool {
197        if &*self.pattern == "\n" || &*self.pattern == "\r\n" {
198            // Fast path for pattern that allows any prefix and suffix.
199            return true;
200        }
201        let mut patterns = self.split_newline_glob();
202        // First line must be a prefix of the text:
203        {
204            let first = patterns
205                .next()
206                .expect("even an empty pattern string should have at least one part");
207            if first.is_empty() {
208                // The glob pattern allows anything at the start of the text.
209            } else if let Some(after) = text.strip_prefix(first) {
210                text = after;
211            } else {
212                return false;
213            };
214        }
215        if let Some(last) = patterns.next_back() {
216            if last.is_empty() {
217                // the glob pattern allows anything at the end of the text.
218            } else if let Some(before) = text.strip_suffix(last) {
219                text = before;
220            } else {
221                return false;
222            };
223        } else {
224            // Only a single line, so after the initial prefix there can't be any more text:
225            return text.is_empty();
226        }
227        // Lines in the middle of the pattern can have anything before or after
228        // their pattern text:
229        for pattern in patterns {
230            if let Some((_, after)) = text.split_once(pattern) {
231                text = after;
232            } else {
233                // The line's text didn't exist in the checked text (at least
234                // not after previous line texts):
235                return false;
236            }
237        }
238        // Prefix and suffix existed + middle lines existed as well:
239        true
240    }
241    pub fn split_newline_glob(&self) -> impl DoubleEndedIterator<Item = &'_ str> {
242        self.pattern
243            .split('\n')
244            .map(|line| line.trim_end_matches('\r'))
245    }
246    /// Visualizes a newline delimited glob pattern in a single line.
247    pub fn display_escaped_newline_glob(&self) -> impl fmt::Display + '_ {
248        struct Helper<'a>(&'a TextPattern);
249        impl fmt::Display for Helper<'_> {
250            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
251                for (ix, part) in self.0.split_newline_glob().enumerate() {
252                    if ix != 0 {
253                        write!(f, "*")?;
254                    }
255                    write!(f, "\"{}\"", part.replace('\\', "\\\\").replace('"', "\\\""))?;
256                }
257                Ok(())
258            }
259        }
260        Helper(self)
261    }
262    pub fn pattern(&self) -> &Arc<str> {
263        &self.pattern
264    }
265}
266impl Default for TextPattern {
267    fn default() -> Self {
268        Self::new(Arc::from("\n"))
269    }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
273#[cfg_attr(feature = "persist_filters", derive(Serialize, Deserialize))]
274pub struct ExportedWindowFilters {
275    pub version: u64,
276    pub filters: Vec<WindowFilter>,
277}
278impl ExportedWindowFilters {
279    pub fn migrate_and_get_filters(self) -> Vec<WindowFilter> {
280        self.filters
281    }
282}
283impl Default for ExportedWindowFilters {
284    fn default() -> Self {
285        Self {
286            version: 1,
287            filters: Vec::new(),
288        }
289    }
290}
291
292/// Specifies how to filter all windows to select a subset and also what action
293/// should be applied to the selected windows.
294#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)]
295#[cfg_attr(feature = "persist_filters", derive(Serialize, Deserialize))]
296pub struct WindowFilter {
297    /// This index is lower if the window was recently accessed. Limiting this
298    /// index is therefore a way to only affect recently used windows.
299    ///
300    /// Note: uses zero-based indexing.
301    pub window_index: IntegerRange,
302    /// The index of the virtual desktop that window should be on. If the window
303    /// is pinned then that is always allowed.
304    ///
305    /// Note: uses zero-based indexing.
306    pub desktop_index: IntegerRange,
307    /// The text in the title bar of a window.
308    pub window_title: TextPattern,
309    /// The name of the process that created and owns a window.
310    pub process_name: TextPattern,
311    /// The action to preform on windows that match all the conditions in this
312    /// filter.
313    pub action: FilterAction,
314    /// If the action specifies that the window should move then this specifies
315    /// the desktop it should be moved to.
316    ///
317    /// Note: uses zero-based indexing.
318    pub target_desktop: i64,
319}
320impl WindowFilter {
321    #[cfg(feature = "persist_filters_xml")]
322    pub fn deserialize_from_xml(xml: &str) -> Result<Vec<Self>, Box<dyn std::error::Error>> {
323        let mut deserializer = quick_xml::de::Deserializer::from_str(xml);
324        #[cfg(not(feature = "serde_path_to_error"))]
325        let data: xml_format::SaveFile = Deserialize::deserialize(&mut deserializer)?;
326        #[cfg(feature = "serde_path_to_error")]
327        let data: xml_format::SaveFile = serde_path_to_error::deserialize(&mut deserializer)?;
328
329        Ok(data
330            .filters
331            .save_data
332            .into_iter()
333            .map(|filter| Self {
334                window_index: IntegerRange {
335                    lower_bound: Some(filter.data.index_lower_bound).filter(|&v| v >= 0),
336                    upper_bound: Some(filter.data.index_upper_bound).filter(|&v| v >= 0),
337                },
338                desktop_index: IntegerRange {
339                    lower_bound: Some(filter.data.desktop_lower_bound).filter(|&v| v >= 0),
340                    upper_bound: Some(filter.data.desktop_upper_bound).filter(|&v| v >= 0),
341                },
342                window_title: if filter.data.title.string.is_empty()
343                    || filter.data.title.string == [""]
344                {
345                    TextPattern::default()
346                } else {
347                    TextPattern::new(Arc::from(filter.data.title.string.join("\n")))
348                },
349                process_name: if filter.data.process.string.is_empty() {
350                    TextPattern::default()
351                } else {
352                    TextPattern::new(Arc::from(filter.data.process.string.join("\n")))
353                },
354                action: if let Some(adv) = &filter.data.desktop_target_adv {
355                    if adv.should_pin {
356                        FilterAction::Pin
357                    } else if adv.target_desktop_index >= 0 {
358                        if adv.allow_unpin {
359                            FilterAction::UnpinAndMove
360                        } else {
361                            FilterAction::Move
362                        }
363                    } else if adv.allow_unpin {
364                        FilterAction::Unpin
365                    } else {
366                        FilterAction::Disabled
367                    }
368                } else if filter.data.desktop_target >= 0 {
369                    FilterAction::Move
370                } else {
371                    FilterAction::Disabled
372                },
373                target_desktop: filter
374                    .data
375                    .desktop_target_adv
376                    .as_ref()
377                    .map(|adv| adv.target_desktop_index)
378                    .unwrap_or_else(|| filter.data.desktop_target)
379                    .max(0),
380            })
381            .collect())
382    }
383    #[cfg(feature = "persist_filters_xml")]
384    pub fn serialize_to_xml(
385        filters: &[WindowFilter],
386    ) -> Result<String, Box<dyn std::error::Error>> {
387        let filters = filters
388            .iter()
389            .map(|filter| xml_format::SaveData {
390                data: xml_format::Data {
391                    index_lower_bound: filter.window_index.lower_bound.unwrap_or(-1),
392                    index_upper_bound: filter.window_index.upper_bound.unwrap_or(-1),
393                    desktop_lower_bound: filter.desktop_index.lower_bound.unwrap_or(-1),
394                    desktop_upper_bound: filter.desktop_index.upper_bound.unwrap_or(-1),
395                    title: xml_format::Title {
396                        string: filter
397                            .window_title
398                            .pattern()
399                            .replace('\r', "")
400                            .split('\n')
401                            .map(String::from)
402                            .collect(),
403                    },
404                    process: xml_format::Process {
405                        string: filter
406                            .process_name
407                            .pattern()
408                            .replace('\r', "")
409                            .split('\n')
410                            .map(String::from)
411                            .collect(),
412                    },
413                    is_main_process_window: false,
414                    check_if_main_window: false,
415                    desktop_target: if matches!(
416                        filter.action,
417                        FilterAction::Move | FilterAction::UnpinAndMove
418                    ) {
419                        filter.target_desktop
420                    } else {
421                        -1
422                    },
423                    desktop_target_adv: Some(xml_format::DesktopTargetAdv {
424                        allow_unpin: matches!(
425                            filter.action,
426                            FilterAction::Unpin | FilterAction::UnpinAndMove
427                        ),
428                        should_pin: matches!(filter.action, FilterAction::Pin),
429                        target_desktop_index: if matches!(
430                            filter.action,
431                            FilterAction::Move | FilterAction::UnpinAndMove | FilterAction::Pin
432                        ) {
433                            filter.target_desktop
434                        } else {
435                            -1
436                        },
437                    }),
438                },
439            })
440            .collect::<Vec<_>>();
441        let xml_data = xml_format::SaveFile {
442            filters: xml_format::Filters { save_data: filters },
443            ..Default::default()
444        };
445        quick_xml::se::to_string(&xml_data).map_err(Into::into)
446    }
447    pub fn find_first_action<'a>(
448        filters: &'a [Self],
449        window_index: i32,
450        window: &WindowInfo,
451    ) -> Option<&'a Self> {
452        filters
453            .iter()
454            // ignore disabled filters:
455            .filter(|filter| filter.action != FilterAction::Disabled)
456            // then find first that can be applied:
457            .find(|filter| filter.check_window(window_index, window))
458    }
459    /// Check if this filter/rule applies to a specific widow.
460    ///
461    /// The `window_index` should use zero-based indexing.
462    pub fn check_window(&self, window_index: i32, window: &WindowInfo) -> bool {
463        if !self.window_index.contains(i64::from(window_index)) {
464            return false;
465        }
466        if let VirtualDesktopInfo::AtDesktop { index, .. } = window.virtual_desktop {
467            if !self.desktop_index.contains(i64::from(index)) {
468                return false;
469            }
470        }
471        if !self.window_title.check_newline_glob(&window.title) {
472            return false;
473        }
474        if !self.process_name.check_newline_glob(&window.process_name) {
475            return false;
476        }
477        true
478    }
479    /// Display a short string with information about the action and the
480    /// targeted desktop. (Displays target desktop with one-based indexing.)
481    pub fn display_target_desktop(&self) -> impl fmt::Display {
482        struct TargetDesktopFmt {
483            action: FilterAction,
484            target_desktop: i64,
485        }
486        impl fmt::Display for TargetDesktopFmt {
487            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488                match self.action {
489                    FilterAction::Move => write!(f, "{}", self.target_desktop),
490                    FilterAction::UnpinAndMove => write!(f, "{} (Unpin)", self.target_desktop),
491                    FilterAction::Unpin => write!(f, "Unpin"),
492                    FilterAction::Pin => write!(f, "Pin"),
493                    FilterAction::Nothing => write!(f, "None"),
494                    FilterAction::Disabled => write!(f, "Disabled"),
495                }
496            }
497        }
498        TargetDesktopFmt {
499            action: self.action,
500            target_desktop: self.target_desktop.saturating_add(1),
501        }
502    }
503}
504
505/// Specifies what action to preform on a window that a [`WindowFilter`] has
506/// selected.
507#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
508#[cfg_attr(feature = "persist_filters", derive(Serialize, Deserialize))]
509pub enum FilterAction {
510    /// Move the window to another virtual desktop. Do nothing if the window is
511    /// pinned and therefore visible on all desktops.
512    Move,
513    /// Move the window to another virtual desktop and if the window was pinned
514    /// then unpin it first.
515    UnpinAndMove,
516    /// Unpin the window so that it is no longer visible on all desktops.
517    Unpin,
518    /// Pin the window so that it becomes visible on all desktops.
519    Pin,
520    /// Do nothing with the window. This can be useful to prevent some windows
521    /// from being affected by any other filter.
522    Nothing,
523    /// Disable this filter. The program will act as if this filter didn't exist.
524    #[default]
525    Disabled,
526}
527impl FilterAction {
528    pub fn as_str(&self) -> &'static str {
529        match self {
530            FilterAction::Move => "Move",
531            FilterAction::UnpinAndMove => "Unpin and move",
532            FilterAction::Unpin => "Unpin",
533            FilterAction::Pin => "Pin",
534            FilterAction::Nothing => "Nothing",
535            FilterAction::Disabled => "Disabled",
536        }
537    }
538}
539/// Used to represent the action inside the configuration window.
540impl fmt::Display for FilterAction {
541    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
542        f.write_str(self.as_str())
543    }
544}