virtual_desktop_manager/
config_window.rs

1use std::{
2    cell::{Cell, OnceCell, RefCell},
3    cmp::Ordering,
4    collections::BTreeMap,
5    fs::OpenOptions,
6    io::Write,
7    path::PathBuf,
8    rc::Rc,
9    str::FromStr,
10    sync::{
11        atomic::{AtomicBool, Ordering as AtomicOrdering},
12        mpsc, Arc,
13    },
14};
15
16use crate::{
17    dynamic_gui::DynamicUiHooks,
18    nwg_ext::{
19        list_view_enable_groups, list_view_item_get_group_id, list_view_item_set_group_id,
20        list_view_set_group_info, list_view_sort_rows, window_is_valid, window_placement,
21        ListViewGroupAlignment, ListViewGroupInfo, NumberSelect2, WindowPlacement,
22    },
23    settings::{
24        AutoStart, ConfigWindowInfo, QuickSwitchMenu, TrayClickAction, TrayIconType, UiSettings,
25    },
26    tray::{SystemTray, SystemTrayRef, TrayPlugin},
27    vd,
28    window_filter::{ExportedWindowFilters, FilterAction, IntegerRange, TextPattern, WindowFilter},
29    window_info::WindowInfo,
30};
31
32struct BackgroundThread {
33    rx: mpsc::Receiver<WindowInfo>,
34    handle: Option<std::thread::JoinHandle<()>>,
35    should_exit: Arc<AtomicBool>,
36}
37impl Drop for BackgroundThread {
38    fn drop(&mut self) {
39        self.should_exit.store(true, AtomicOrdering::Release);
40        let Some(handle) = self.handle.take() else {
41            return;
42        };
43        let res = handle.join();
44        if !std::thread::panicking() {
45            res.unwrap();
46        }
47    }
48}
49
50// Stretch style
51use nwg::stretch::{
52    geometry::{Rect, Size},
53    style::{AlignSelf, Dimension as D, FlexDirection},
54};
55const fn uniform_rect<D: Copy>(size: D) -> Rect<D> {
56    Rect {
57        start: size,
58        end: size,
59        top: size,
60        bottom: size,
61    }
62}
63const MARGIN: Rect<D> = uniform_rect(D::Points(5.0));
64const TAB_BACKGROUND: Option<[u8; 3]> = Some([255, 255, 255]);
65
66#[derive(nwd::NwgPartial, nwd::NwgUi, Default)]
67pub struct ConfigWindow {
68    tray: SystemTrayRef,
69
70    sidebar_layout: nwg::FlexboxLayout,
71    layout: nwg::FlexboxLayout,
72
73    tooltips: nwg::Tooltip,
74
75    #[nwg_control(
76        size: data.create_window_with_size(),
77        position: data.create_window_with_position(),
78        maximized: data.create_window_with_maximized(),
79        title: "Virtual Desktop Manager",
80        icon: crate::exe_icon().as_deref(),
81    )]
82    #[nwg_events(
83        OnWindowClose: [Self::on_close],
84        OnInit: [Self::on_init],
85        OnResizeEnd: [Self::on_resize_end],
86        OnMove: [Self::on_move],
87        OnMinMaxInfo: [Self::on_window_min_max_info(SELF, EVT_DATA)],
88    )]
89    pub window: nwg::Window,
90
91    #[nwg_control(
92        item_count: 10,
93        size: (500, 350),
94        list_style: nwg::ListViewStyle::Detailed,
95        focus: true,
96        ex_flags:
97            nwg::ListViewExFlags::GRID |
98            nwg::ListViewExFlags::FULL_ROW_SELECT |
99            nwg::ListViewExFlags::HEADER_DRAG_DROP,
100    )]
101    // Note: nwg_layout_item attribute info was written when layout was defined.
102    #[nwg_events(
103        OnListViewColumnClick: [Self::on_column_click(SELF, EVT_DATA)],
104        OnListViewItemActivated: [Self::on_list_view_item_activated(SELF, EVT_DATA)],
105    )]
106    data_view: nwg::ListView,
107    loaded_window_info: RefCell<Vec<WindowInfo>>,
108    loaded_filters: RefCell<Option<Arc<[WindowFilter]>>>,
109
110    #[nwg_control(parent: window)]
111    sidebar_tab_container: nwg::TabsContainer,
112
113    #[nwg_control(parent: sidebar_tab_container, text: "Filter options")]
114    filter_tab: nwg::Tab,
115
116    #[nwg_control(
117        parent: filter_tab, position: (5, 5), size: (230, 25),
118        text: "Selected filter index:",
119        background_color: TAB_BACKGROUND,
120    )]
121    filter_select_label: nwg::Label,
122
123    #[nwg_control(
124        parent: filter_tab, position: (5, 30), size: (225, 25),
125        min_int: 0, value_int: 0,
126    )]
127    #[nwg_events(OnNotice: [Self::on_select_filter_index_changed])]
128    filter_select_index: NumberSelect2,
129    selected_filter_index: Cell<Option<usize>>,
130
131    #[nwg_control(parent: filter_tab, position: (5, 60), size: (130, 25), text: "Create new filter")]
132    #[nwg_events(OnButtonClick: [Self::on_create_filter])]
133    filter_create_button: nwg::Button,
134
135    #[nwg_control(parent: filter_tab, position: (140, 60), size: (90, 25), text: "Delete filter")]
136    #[nwg_events(OnButtonClick: [Self::on_delete_current_filter])]
137    filter_delete_button: nwg::Button,
138
139    #[nwg_control(
140        parent: filter_tab, position: (5, 95), size: (230, 25),
141        text: "Window index:",
142        background_color: TAB_BACKGROUND,
143    )]
144    filter_window_index_label: nwg::Label,
145
146    #[nwg_control(
147        parent: filter_tab, position: (5, 115), size: (110, 25),
148        text: "Lower bound",
149        background_color: TAB_BACKGROUND,
150    )]
151    #[nwg_events(OnButtonClick: [Self::on_filter_config_ui_changed])]
152    filter_window_index_lower_checkbox: nwg::CheckBox,
153
154    #[nwg_control(
155        parent: filter_tab, position: (125, 115), size: (110, 25),
156        text: "Upper bound",
157        background_color: TAB_BACKGROUND,
158    )]
159    #[nwg_events(OnButtonClick: [Self::on_filter_config_ui_changed])]
160    filter_window_index_upper_checkbox: nwg::CheckBox,
161
162    #[nwg_control(
163        parent: filter_tab, position: (5, 140), size: (110, 25),
164        min_int: 1, value_int: 1,
165    )]
166    #[nwg_events(OnNotice: [Self::on_filter_config_ui_changed])]
167    filter_window_index_lower: NumberSelect2,
168
169    #[nwg_control(
170        parent: filter_tab, position: (125, 140), size: (110, 25),
171        min_int: 1, value_int: 1,
172    )]
173    #[nwg_events(OnNotice: [Self::on_filter_config_ui_changed])]
174    filter_window_index_upper: NumberSelect2,
175
176    #[nwg_control(
177        parent: filter_tab, position: (5, 175), size: (230, 25),
178        text: "Virtual desktop index:",
179        background_color: TAB_BACKGROUND,
180    )]
181    filter_desktop_index_label: nwg::Label,
182
183    #[nwg_control(
184        parent: filter_tab, position: (5, 195), size: (110, 25),
185        text: "Lower bound",
186        background_color: TAB_BACKGROUND,
187    )]
188    #[nwg_events(OnButtonClick: [Self::on_filter_config_ui_changed])]
189    filter_desktop_index_lower_checkbox: nwg::CheckBox,
190
191    #[nwg_control(
192        parent: filter_tab, position: (125, 195), size: (110, 25),
193        text: "Upper bound",
194        background_color: TAB_BACKGROUND,
195    )]
196    #[nwg_events(OnButtonClick: [Self::on_filter_config_ui_changed])]
197    filter_desktop_index_upper_checkbox: nwg::CheckBox,
198
199    #[nwg_control(
200        parent: filter_tab, position: (5, 225), size: (110, 25),
201        min_int: 1, value_int: 1,
202    )]
203    #[nwg_events(OnNotice: [Self::on_filter_config_ui_changed])]
204    filter_desktop_index_lower: NumberSelect2,
205
206    #[nwg_control(
207        parent: filter_tab, position: (125, 225), size: (110, 25),
208        min_int: 1, value_int: 1,
209    )]
210    #[nwg_events(OnNotice: [Self::on_filter_config_ui_changed])]
211    filter_desktop_index_upper: NumberSelect2,
212
213    #[nwg_control(
214        parent: filter_tab, position: (5, 260), size: (230, 25),
215        text: "Window title:",
216        background_color: TAB_BACKGROUND,
217    )]
218    filter_title_label: nwg::Label,
219
220    #[nwg_control(parent: filter_tab, position: (5, 285), size: (230, 85))]
221    #[nwg_events(OnTextInput: [Self::on_filter_config_ui_changed])]
222    filter_title: nwg::TextBox,
223
224    #[nwg_control(
225        parent: filter_tab, position: (5, 375), size: (230, 25),
226        text: "Process name:",
227        background_color: TAB_BACKGROUND,
228    )]
229    filter_process_label: nwg::Label,
230
231    #[nwg_control(parent: filter_tab, position: (5, 400), size: (230, 85))]
232    #[nwg_events(OnTextInput: [Self::on_filter_config_ui_changed])]
233    filter_process: nwg::TextBox,
234
235    #[nwg_control(
236        parent: filter_tab, position: (5, 495), size: (230, 25),
237        text: "Virtual desktop action to apply:",
238        background_color: TAB_BACKGROUND,
239    )]
240    filter_action_label: nwg::Label,
241
242    #[nwg_control(
243        parent: filter_tab, position: (5, 520), size: (230, 25),
244        collection: vec![FilterAction::Move, FilterAction::UnpinAndMove, FilterAction::Unpin, FilterAction::Pin, FilterAction::Nothing, FilterAction::Disabled],
245        selected_index: Some(5),
246    )]
247    #[nwg_events(OnComboxBoxSelection: [Self::on_filter_config_ui_changed])]
248    filter_action: nwg::ComboBox<FilterAction>,
249
250    #[nwg_control(
251        parent: filter_tab, position: (5, 555), size: (230, 25),
252        text: "Move to virtual desktop at index:",
253        background_color: TAB_BACKGROUND,
254    )]
255    filter_target_desktop_label: nwg::Label,
256
257    #[nwg_control(
258        parent: filter_tab, position: (5, 580), size: (225, 25),
259        min_int: 1, value_int: 1,
260    )]
261    #[nwg_events(OnNotice: [Self::on_filter_config_ui_changed])]
262    filter_target_desktop: NumberSelect2,
263
264    #[nwg_control(parent: sidebar_tab_container, text: "Program settings")]
265    settings_tab: nwg::Tab,
266
267    #[nwg_control(
268        parent: settings_tab, position: (5, 5), size: (240, 25),
269        text: "Start program with admin rights",
270        background_color: TAB_BACKGROUND,
271    )]
272    #[nwg_events(OnButtonClick: [Self::on_settings_ui_changed])]
273    settings_start_as_admin: nwg::CheckBox,
274
275    #[nwg_control(
276        parent: settings_tab, position: (5, 35), size: (240, 25),
277        text: "Auto start with Windows:",
278        background_color: TAB_BACKGROUND,
279    )]
280    settings_auto_start_label: nwg::Label,
281
282    #[nwg_control(
283        parent: settings_tab, position: (5, 60), size: (240, 25),
284        collection: AutoStart::ALL.to_vec(),
285        selected_index: Some(0),
286    )]
287    #[nwg_events(OnComboxBoxSelection: [Self::on_settings_ui_changed])]
288    settings_auto_start: nwg::ComboBox<AutoStart>,
289
290    #[nwg_control(
291        parent: settings_tab, position: (5, 95), size: (240, 25),
292        text: "Prevent flashing windows",
293        background_color: TAB_BACKGROUND,
294    )]
295    #[nwg_events(OnButtonClick: [Self::on_settings_ui_changed])]
296    settings_prevent_flashing_windows: nwg::CheckBox,
297
298    #[nwg_control(
299        parent: settings_tab, position: (5, 125), size: (240, 25),
300        text: "Smoothly switch virtual desktop",
301        background_color: TAB_BACKGROUND,
302    )]
303    #[nwg_events(OnButtonClick: [Self::on_settings_ui_changed])]
304    settings_smooth_switch_desktop: nwg::CheckBox,
305
306    #[nwg_control(
307        parent: settings_tab, position: (5, 155), size: (240, 25),
308        text: "Tray icon:",
309        background_color: TAB_BACKGROUND,
310    )]
311    settings_tray_icon_label: nwg::Label,
312
313    #[nwg_control(
314        parent: settings_tab, position: (5, 180), size: (240, 25),
315        collection: TrayIconType::ALL.to_vec(),
316        selected_index: Some(0),
317    )]
318    #[nwg_events(OnComboxBoxSelection: [Self::on_settings_ui_changed])]
319    settings_tray_icon: nwg::ComboBox<TrayIconType>,
320
321    #[nwg_control(
322        parent: settings_tab, position: (5, 215), size: (240, 25),
323        text: "Quick switch context menu:",
324        background_color: TAB_BACKGROUND,
325    )]
326    settings_quick_menu_label: nwg::Label,
327
328    #[nwg_control(
329        parent: settings_tab, position: (5, 240), size: (240, 25),
330        collection: QuickSwitchMenu::ALL.to_vec(),
331        selected_index: Some(0),
332    )]
333    #[nwg_events(OnComboxBoxSelection: [Self::on_settings_ui_changed])]
334    settings_quick_menu: nwg::ComboBox<QuickSwitchMenu>,
335
336    #[nwg_control(
337        parent: settings_tab, position: (5, 275), size: (240, 25),
338        text: "Quick switch menu shortcuts:",
339        background_color: TAB_BACKGROUND,
340    )]
341    settings_quick_menu_shortcuts_label: nwg::Label,
342
343    #[nwg_control(parent: settings_tab, position: (5, 300), size: (240, 85))]
344    #[nwg_events(OnTextInput: [Self::on_settings_ui_changed])]
345    settings_quick_menu_shortcuts: nwg::TextBox,
346
347    #[nwg_control(
348        parent: settings_tab, position: (5, 395), size: (240, 25),
349        text: "Quick shortcuts in submenus",
350        background_color: TAB_BACKGROUND,
351    )]
352    #[nwg_events(OnButtonClick: [Self::on_settings_ui_changed])]
353    settings_quick_menu_shortcuts_in_submenus: nwg::CheckBox,
354
355    #[nwg_control(
356        parent: settings_tab, position: (5, 430), size: (240, 25),
357        text: "Global hotkey for quick switch:",
358        background_color: TAB_BACKGROUND,
359    )]
360    settings_quick_menu_hotkey_label: nwg::Label,
361
362    #[nwg_control(parent: settings_tab, position: (5, 455), size: (240, 28))]
363    #[nwg_events(OnTextInput: [Self::on_settings_ui_changed])]
364    settings_quick_menu_hotkey: nwg::TextInput,
365
366    #[nwg_control(parent: settings_tab,
367        position: (5, 490), size: (240, 46),
368        readonly: true,
369        flags: "HSCROLL | AUTOHSCROLL | TAB_STOP | VISIBLE",
370    )]
371    settings_quick_menu_hotkey_error: nwg::TextBox,
372
373    #[nwg_control(
374        parent: settings_tab, position: (5, 550), size: (240, 25),
375        text: "Left click on tray icon:",
376        background_color: TAB_BACKGROUND,
377    )]
378    settings_left_click_label: nwg::Label,
379
380    #[nwg_control(
381        parent: settings_tab, position: (5, 575), size: (240, 25),
382        collection: TrayClickAction::ALL.to_vec(),
383        selected_index: Some(0),
384    )]
385    #[nwg_events(OnComboxBoxSelection: [Self::on_settings_ui_changed])]
386    settings_left_click: nwg::ComboBox<TrayClickAction>,
387
388    #[nwg_control(
389        parent: settings_tab, position: (5, 610), size: (240, 25),
390        text: "Middle click on tray icon:",
391        background_color: TAB_BACKGROUND,
392    )]
393    settings_middle_click_label: nwg::Label,
394
395    #[nwg_control(
396        parent: settings_tab, position: (5, 635), size: (240, 25),
397        collection: TrayClickAction::ALL.to_vec(),
398        selected_index: Some(0),
399    )]
400    #[nwg_events(OnComboxBoxSelection: [Self::on_settings_ui_changed])]
401    settings_middle_click: nwg::ComboBox<TrayClickAction>,
402
403    #[nwg_control(
404        parent: settings_tab, position: (5, 680), size: (240, 40),
405        text: "Global hotkey to open context\r\nmenu at current mouse position:",
406        background_color: TAB_BACKGROUND,
407    )]
408    settings_open_menu_at_mouse_pos_hotkey_label: nwg::Label,
409
410    #[nwg_control(parent: settings_tab, position: (5, 680 + 50), size: (240, 28))]
411    #[nwg_events(OnTextInput: [Self::on_settings_ui_changed])]
412    settings_open_menu_at_mouse_pos_hotkey: nwg::TextInput,
413
414    #[nwg_control(parent: settings_tab,
415        position: (5, 680 + 50 + 35), size: (240, 46),
416        readonly: true,
417        flags: "HSCROLL | AUTOHSCROLL | TAB_STOP | VISIBLE",
418    )]
419    settings_open_menu_at_mouse_pos_hotkey_error: nwg::TextBox,
420
421    #[nwg_control(parent: window, flags: "VISIBLE")]
422    utils_frame: nwg::Frame,
423
424    #[nwg_control(parent: utils_frame, position: (0, 5), size: (125, 30), text: "Import filters")]
425    #[nwg_events(OnButtonClick: [Self::on_import_filters])]
426    utils_import: nwg::Button,
427
428    #[nwg_control(parent: utils_frame, position: (130, 5), size: (130, 30), text: "Export filters")]
429    #[nwg_events(OnButtonClick: [Self::on_export_filters])]
430    utils_export: nwg::Button,
431
432    #[nwg_control(parent: utils_frame, position: (0, 45), size: (100, 55), text: "Refresh info")]
433    #[nwg_events(OnButtonClick: [Self::on_refresh_info])]
434    utils_refresh: nwg::Button,
435
436    #[nwg_control(parent: utils_frame, position: (105, 45), size: (155, 55), text: "Apply filters")]
437    #[nwg_events(OnButtonClick: [Self::on_apply_filters])]
438    utils_apply_filters: nwg::Button,
439
440    background_thread: RefCell<Option<BackgroundThread>>,
441    has_queued_refresh: Cell<bool>,
442    is_data_sorted: Cell<bool>,
443
444    #[nwg_control(parent: window)]
445    #[nwg_events(OnNotice: [Self::on_data])]
446    data_notice: nwg::Notice,
447
448    is_closed: Cell<bool>,
449    pub open_soon: Cell<bool>,
450
451    export_dialog: OnceCell<nwg::FileDialog>,
452    import_dialog: OnceCell<nwg::FileDialog>,
453}
454/// Setup code
455impl ConfigWindow {
456    const GROUP_WINDOWS: i32 = 1;
457    const GROUP_FILTERS: i32 = 2;
458
459    const COLUMN_WINDOWS_INDEX: usize = 0;
460    const COLUMN_FILTERS_INDEX: usize = 4;
461    const COLUMN_TARGET_DESKTOP: usize = 5;
462
463    fn create_window_with_size(&self) -> (i32, i32) {
464        let (x, y) = self
465            .tray
466            .get()
467            .map(|tray| tray.settings().get().config_window)
468            .unwrap_or_default()
469            .size;
470        let (min_x, min_y) = Self::MIN_SIZE;
471        ((x as i32).max(min_x), (y as i32).max(min_y))
472    }
473    fn create_window_with_position(&self) -> (i32, i32) {
474        self.tray
475            .get()
476            .and_then(|tray| tray.settings().get().config_window.position)
477            .unwrap_or((300, 300))
478    }
479    fn create_window_with_maximized(&self) -> bool {
480        self.tray
481            .get()
482            .map(|tray| tray.settings().get().config_window)
483            .unwrap_or_default()
484            .maximized
485    }
486
487    fn build_layout(&self) -> Result<(), nwg::NwgError> {
488        let ui = self;
489
490        // layout for the sidebar on the right side of the window:
491        let mut sidebar_layout = nwg::FlexboxLayout::builder()
492            .parent(&ui.window)
493            .flex_direction(FlexDirection::Column);
494        // First we have the "configuration" area with different tabs:
495        sidebar_layout = sidebar_layout
496            .child(&ui.sidebar_tab_container)
497            .child_margin(MARGIN)
498            .child_align_self(AlignSelf::Stretch)
499            .child_flex_grow(1.0)
500            .child_size(Size {
501                width: D::Points(260.0),
502                height: D::Auto,
503            });
504        // Then we have an area for buttons affecting the data table to the left:
505        sidebar_layout = sidebar_layout
506            .child(&ui.utils_frame)
507            .child_margin(MARGIN)
508            .child_align_self(AlignSelf::Stretch)
509            .child_size(Size {
510                width: D::Points(260.0),
511                height: D::Points(100.0),
512            });
513        // Note: use build_partial here since it is a child layout
514        sidebar_layout.build_partial(&ui.sidebar_layout)?;
515
516        // Top-most layout of window (uses build, not build_partial):
517        let mut main_layout = nwg::FlexboxLayout::builder()
518            .parent(&ui.window)
519            .flex_direction(FlexDirection::Row)
520            .padding(uniform_rect(D::Points(5.0)));
521        // The table with windows and filters comes first and fills most of the space:
522        main_layout = main_layout
523            .child(&ui.data_view)
524            .child_margin(MARGIN)
525            .child_flex_grow(1.0)
526            .child_size(Size {
527                width: D::Auto,
528                height: D::Auto,
529            });
530        // Then we register the sidebar sub-layout that should be 250px wide:
531        main_layout = main_layout
532            .child_layout(&ui.sidebar_layout)
533            .child_size(Size {
534                width: D::Points(270.0),
535                height: D::Auto,
536            })
537            .child_align_self(AlignSelf::Stretch);
538        main_layout.build(&ui.layout)?;
539        Ok(())
540    }
541    fn build_tooltip(&mut self) -> Result<(), nwg::NwgError> {
542        nwg::Tooltip::builder()
543            .register(
544                self.settings_start_as_admin.handle,
545                "This is useful in order to move windows owned by other \
546                programs that have admin rights.",
547            )
548            .register(
549                self.settings_prevent_flashing_windows.handle,
550                "Some windows can try to grab attention by flashing their \
551                icon in the taskbar, this option suppresses such flashing right \
552                after window filters are applied.",
553            )
554            .register(
555                self.settings_smooth_switch_desktop.handle,
556                "Enable for this program to use animations when changing \
557                the current virtual desktop.",
558            )
559            .register(
560                self.utils_import.handle,
561                "Add new filters by loading them from a selected file.",
562            )
563            .register(self.utils_export.handle, "Save all filters to a file")
564            .register(
565                self.utils_refresh.handle,
566                "Reload info about all open windows",
567            )
568            .register(
569                self.utils_apply_filters.handle,
570                "Use the configured filters to move windows to specific virtual desktops",
571            )
572            .register(
573                &self.settings_quick_menu_shortcuts_label,
574                "Each line should have a letter or symbol followed by a zero-based \
575                virtual desktop index. For each line an extra context menu item will \
576                be created in the quick switch menu with that symbol as its access key.",
577            )
578            .register(
579                &self.settings_quick_menu_shortcuts_in_submenus,
580                "If checked then extra context menu items for quick switch shortcuts \
581                will be created in each submenu of the quick switch menu when there are \
582                more than 9 virtual desktops.",
583            )
584            .register(
585                &self.settings_middle_click_label,
586                "Controls the action that will be preformed when the tray icon \
587                is middle clicked. On some Windows 11 versions middle clicks are \
588                registered as left clicks.",
589            )
590            .build(&mut self.tooltips)?;
591        Ok(())
592    }
593
594    fn on_init(&self) {
595        let dv = &self.data_view;
596
597        dv.set_headers_enabled(true);
598
599        debug_assert_eq!(Self::COLUMN_WINDOWS_INDEX, dv.column_len());
600        dv.insert_column(nwg::InsertListViewColumn {
601            index: Some(dv.column_len() as _),
602            fmt: Some(nwg::ListViewColumnFlags::LEFT),
603            width: Some(100),
604            text: Some("Window Index".into()),
605        });
606
607        dv.insert_column(nwg::InsertListViewColumn {
608            index: Some(dv.column_len() as _),
609            fmt: Some(nwg::ListViewColumnFlags::LEFT),
610            width: Some(100),
611            text: Some("Virtual Desktop".into()),
612        });
613
614        dv.insert_column(nwg::InsertListViewColumn {
615            index: Some(dv.column_len() as _),
616            fmt: Some(nwg::ListViewColumnFlags::LEFT),
617            width: Some(200),
618            text: Some("Window Title".into()),
619        });
620
621        dv.insert_column(nwg::InsertListViewColumn {
622            index: Some(dv.column_len() as _),
623            fmt: Some(nwg::ListViewColumnFlags::LEFT),
624            width: Some(200),
625            text: Some("Process Name".into()),
626        });
627
628        debug_assert_eq!(Self::COLUMN_FILTERS_INDEX, dv.column_len());
629        dv.insert_column(nwg::InsertListViewColumn {
630            index: Some(dv.column_len() as _),
631            fmt: Some(nwg::ListViewColumnFlags::LEFT),
632            width: Some(100),
633            text: Some("Filter Index".into()),
634        });
635
636        debug_assert_eq!(Self::COLUMN_TARGET_DESKTOP, dv.column_len());
637        dv.insert_column(nwg::InsertListViewColumn {
638            index: Some(dv.column_len() as _),
639            fmt: Some(nwg::ListViewColumnFlags::LEFT),
640            width: Some(100),
641            text: Some("Target Desktop".into()),
642        });
643
644        dv.set_column_sort_arrow(0, None);
645
646        list_view_enable_groups(dv, true);
647        list_view_set_group_info(
648            dv,
649            ListViewGroupInfo {
650                create_new: true,
651                group_id: Self::GROUP_WINDOWS,
652                header: Some("Active Windows".into()),
653                header_alignment: Some(ListViewGroupAlignment::Left),
654                ..Default::default()
655            },
656        );
657        list_view_set_group_info(
658            dv,
659            ListViewGroupInfo {
660                create_new: true,
661                group_id: Self::GROUP_FILTERS,
662                header: Some("Filters / Rules".into()),
663                header_alignment: Some(ListViewGroupAlignment::Left),
664                ..Default::default()
665            },
666        );
667
668        self.sync_filter_from_settings(None);
669        self.set_selected_filter_index(Some(0));
670        self.gather_window_info();
671    }
672}
673/// Sort list view.
674impl ConfigWindow {
675    fn on_column_click(&self, data: &nwg::EventData) {
676        let &nwg::EventData::OnListViewItemIndex { column_index, .. } = data else {
677            tracing::error!(event_data = ?data, "ConfigWindow::on_column_click: got unexpected event data");
678            return;
679        };
680        tracing::trace!(event_data = ?data, "ConfigWindow::on_column_click");
681
682        let sort_dir = self.data_view.column_sort_arrow(column_index);
683        let new_sort_dir = match sort_dir {
684            Some(nwg::ListViewColumnSortArrow::Up)
685                if column_index == Self::COLUMN_WINDOWS_INDEX =>
686            {
687                None
688            }
689            Some(nwg::ListViewColumnSortArrow::Up) => Some(nwg::ListViewColumnSortArrow::Down),
690            Some(nwg::ListViewColumnSortArrow::Down) => Some(nwg::ListViewColumnSortArrow::Up),
691            None => Some(nwg::ListViewColumnSortArrow::Down),
692        };
693        tracing::debug!(column_index, ?sort_dir, ?new_sort_dir, "on_column_click");
694        self.data_view
695            .set_column_sort_arrow(column_index, new_sort_dir);
696        for i in 0..self.data_view.column_len() {
697            if i == column_index {
698                continue;
699            }
700            self.data_view.set_column_sort_arrow(i, None);
701        }
702        self.sort_items(
703            Some(column_index).filter(|_| new_sort_dir.is_some()),
704            new_sort_dir,
705        );
706    }
707    fn get_sort_info(&self) -> (Option<usize>, Option<nwg::ListViewColumnSortArrow>) {
708        for i in 0..self.data_view.column_len() {
709            let sort_dir = self.data_view.column_sort_arrow(i);
710            if sort_dir.is_some() {
711                return (Some(i), sort_dir);
712            }
713        }
714        (None, None)
715    }
716    fn resort_items(&self) {
717        let (index, sort_dir) = self.get_sort_info();
718        self.sort_items(index, sort_dir);
719    }
720    fn sort_items(
721        &self,
722        column_index: Option<usize>,
723        sort_dir: Option<nwg::ListViewColumnSortArrow>,
724    ) {
725        list_view_sort_rows(&self.data_view, |a_ix, b_ix| {
726            let a_group = list_view_item_get_group_id(&self.data_view, a_ix);
727            let b_group = list_view_item_get_group_id(&self.data_view, b_ix);
728            let group_cmp = a_group.cmp(&b_group);
729            if group_cmp.is_ne() {
730                // Sort by group first:
731                return group_cmp;
732            }
733
734            let mut using_fallback = column_index.is_none();
735            let result = loop {
736                let current_column_index = if using_fallback {
737                    if a_group == Self::GROUP_FILTERS {
738                        Self::COLUMN_FILTERS_INDEX
739                    } else if a_group == Self::GROUP_WINDOWS {
740                        Self::COLUMN_WINDOWS_INDEX
741                    } else {
742                        tracing::warn!("Tried to sort row that was neither a window or a filter");
743                        column_index.unwrap_or_default()
744                    }
745                } else {
746                    column_index.unwrap_or_default()
747                };
748                let a = self.data_view.item(a_ix, current_column_index, 4096);
749                let b = self.data_view.item(b_ix, current_column_index, 4096);
750                let (a, b) = match (a, b) {
751                    (Some(a), Some(b)) => (a, b),
752                    (None, Some(_)) => {
753                        tracing::warn!("Failed to get list item at row {}", a_ix);
754                        // First item likely had too long text (so put it last):
755                        return Ordering::Greater;
756                    }
757                    (Some(_), None) => {
758                        tracing::warn!("Failed to get list item at row {}", b_ix);
759                        return Ordering::Less;
760                    }
761                    (None, None) => {
762                        tracing::warn!("Failed to get list item at row {} and row {}", a_ix, b_ix);
763                        return Ordering::Equal;
764                    }
765                };
766
767                let result = match a
768                    .text
769                    .parse::<i64>()
770                    .and_then(|a| Ok((a, b.text.parse::<i64>()?)))
771                {
772                    Ok((a, b)) => a.cmp(&b),
773                    Err(_) => a.text.cmp(&b.text),
774                };
775                if result.is_ne() || using_fallback {
776                    break result;
777                } else {
778                    using_fallback = true;
779                }
780            };
781            if using_fallback {
782                result
783            } else if let Some(nwg::ListViewColumnSortArrow::Up) = sort_dir {
784                result.reverse()
785            } else {
786                result
787            }
788        });
789        self.is_data_sorted.set(true);
790    }
791}
792/// Manage window info inside list view.
793impl ConfigWindow {
794    fn clear_window_info(&self) {
795        for ix in (0..self.data_view.len()).rev() {
796            let group = list_view_item_get_group_id(&self.data_view, ix);
797            if group == Self::GROUP_WINDOWS {
798                self.data_view.remove_item(ix);
799            }
800        }
801        self.loaded_window_info.replace(Vec::new());
802    }
803    fn determine_active_filter_indexes_for_window(
804        &self,
805        window_index: i32,
806        window: &WindowInfo,
807    ) -> String {
808        self.loaded_filters
809            .borrow()
810            .as_deref()
811            .unwrap_or_default()
812            .iter()
813            .enumerate()
814            // Find filters/rules that apply to this window:
815            .filter(|(_, rule)| rule.check_window(window_index, window))
816            // one-based indexes:
817            .map(|(ix, _)| (ix + 1).to_string())
818            .collect::<Vec<_>>()
819            .join(", ")
820    }
821    fn add_window_info(&self, window: WindowInfo) {
822        let index = {
823            let mut guard = self.loaded_window_info.borrow_mut();
824            let index = guard.len();
825            guard.push(window.clone());
826            index
827        };
828
829        let filter_indexes = self.determine_active_filter_indexes_for_window(index as i32, &window);
830        let action = WindowFilter::find_first_action(
831            self.loaded_filters.borrow().as_deref().unwrap_or_default(),
832            index as i32,
833            &window,
834        )
835        .map(|filter| filter.display_target_desktop().to_string());
836
837        let WindowInfo {
838            handle: _,
839            title,
840            process_id: _,
841            process_name,
842            virtual_desktop,
843        } = window;
844
845        let virtual_desktop = format!("{virtual_desktop}");
846        let one_based_index = (index + 1).to_string();
847        let info = [
848            one_based_index.as_str(),
849            virtual_desktop.as_str(),
850            title.as_str(),
851            &*process_name,
852            filter_indexes.as_str(),
853            action.as_deref().unwrap_or_default(),
854        ];
855        self.data_view.insert_items_row(None, &info);
856        list_view_item_set_group_id(
857            &self.data_view,
858            self.data_view.len().saturating_sub(1),
859            Some(Self::GROUP_WINDOWS),
860        );
861        self.is_data_sorted.set(false);
862    }
863    fn update_window_infos(&self) {
864        for row_ix in (0..self.data_view.len()).rev() {
865            let group = list_view_item_get_group_id(&self.data_view, row_ix);
866            if group != Self::GROUP_WINDOWS {
867                continue;
868            }
869            let Some(window_index_item) =
870                self.data_view.item(row_ix, Self::COLUMN_WINDOWS_INDEX, 10)
871            else {
872                continue;
873            };
874
875            let Ok(window_index) = window_index_item.text.parse::<usize>() else {
876                continue;
877            };
878            // UI has one-based index:
879            let window_index = window_index - 1;
880            let Some(window_info) = self.loaded_window_info.borrow().get(window_index).cloned()
881            else {
882                continue;
883            };
884
885            let filter_indexes =
886                self.determine_active_filter_indexes_for_window(window_index as i32, &window_info);
887            self.data_view.update_item(
888                row_ix,
889                nwg::InsertListViewItem {
890                    index: Some(row_ix as _),
891                    column_index: Self::COLUMN_FILTERS_INDEX as _,
892                    text: Some(filter_indexes),
893                    image: None,
894                },
895            );
896
897            let action = WindowFilter::find_first_action(
898                self.loaded_filters.borrow().as_deref().unwrap_or_default(),
899                window_index as i32,
900                &window_info,
901            )
902            .map(|filter| filter.display_target_desktop().to_string());
903            self.data_view.update_item(
904                row_ix,
905                nwg::InsertListViewItem {
906                    index: Some(row_ix as _),
907                    column_index: Self::COLUMN_TARGET_DESKTOP as _,
908                    text: Some(action.unwrap_or_default()),
909                    image: None,
910                },
911            );
912        }
913    }
914
915    fn gather_window_info(&self) {
916        let mut guard = self.background_thread.borrow_mut();
917        if matches!(
918            &*guard,
919            Some(BackgroundThread { handle: Some(handle), should_exit, .. })
920            if !handle.is_finished() && !should_exit.load(AtomicOrdering::Acquire)
921        ) {
922            self.has_queued_refresh.set(true);
923            return; // Wait for previous operation
924        }
925        self.clear_window_info();
926        self.has_queued_refresh.set(false);
927
928        let (tx, rx) = mpsc::channel();
929        let notice_tx = self.data_notice.sender();
930        let should_exit = <Arc<AtomicBool>>::default();
931        let handle = std::thread::Builder::new()
932                .name("ConfigWindowBackgroundThread".to_owned())
933                .spawn({
934                    let should_exit = Arc::clone(&should_exit);
935                    move || {
936                        if vd::has_loaded_dynamic_library_successfully() {
937                            // Old .dll files might not call `CoInitialize` and then not work,
938                            // so to be safe we make sure to do that:
939                            if let Err(e) = unsafe { windows::Win32::System::Com::CoInitialize(None) }.ok() {
940                                tracing::warn!(
941                                    error = e.to_string(),
942                                    "Failed to call CoInitialize on ConfigWindowBackgroundThread"
943                                );
944                            }
945                        }
946                        for result in WindowInfo::try_get_all() {
947                            if let Ok(window) = result {
948                                tracing::trace!(info = ?window, "Sending window info to config window");
949                                if tx.send(window).is_err() {
950                                    tracing::debug!("Canceled config window background thread since receiver was closed");
951                                    return;
952                                }
953                                notice_tx.notice();
954                            }
955                            if should_exit.load(AtomicOrdering::Relaxed) {
956                                tracing::debug!(
957                                    "Canceled config window background thread since it was requested"
958                                );
959                                return;
960                            }
961                        }
962                        should_exit.store(true, AtomicOrdering::Relaxed);
963                        // Drop tx and send notice so that ui knows we are done:
964                        drop(tx);
965                        notice_tx.notice();
966                        tracing::debug!(
967                            "Config window background thread has gathered info about all windows"
968                        );
969                    }
970                }).expect("Failed to spawn config window thread");
971
972        // If there already was a background thread here then it will be stopped:
973        *guard = Some(BackgroundThread {
974            rx,
975            handle: Some(handle),
976            should_exit,
977        });
978    }
979    fn on_data(&self) {
980        let Ok(guard) = self.background_thread.try_borrow() else {
981            tracing::warn!("Received notice from background thread while RefCell was locked, might delay a table update");
982            return;
983        };
984        let Some(background) = &*guard else {
985            tracing::warn!(
986                "Received notice from background thread, but no such thread was running"
987            );
988            return;
989        };
990        tracing::trace!("ConfigWindow::on_data");
991        loop {
992            match background.rx.try_recv() {
993                Ok(window) => {
994                    tracing::trace!(info = ?window, "Received window info from background thread");
995                    self.add_window_info(window);
996                    continue;
997                }
998                Err(mpsc::TryRecvError::Disconnected) => {
999                    // Got all data!
1000                    drop(guard);
1001                    self.on_gathered_all_window_info();
1002                }
1003                Err(mpsc::TryRecvError::Empty) => {
1004                    // Will get more data later
1005                }
1006            }
1007            break;
1008        }
1009    }
1010    fn on_gathered_all_window_info(&self) {
1011        if !self.is_data_sorted.get() {
1012            self.resort_items();
1013        }
1014        if self.has_queued_refresh.get() {
1015            self.gather_window_info();
1016        }
1017    }
1018}
1019/// Window events and helper methods.
1020impl ConfigWindow {
1021    const MIN_SIZE: (i32, i32) = (300, 1025);
1022
1023    pub fn is_closed(&self) -> bool {
1024        self.is_closed.get() || !window_is_valid(self.window.handle)
1025    }
1026    pub fn set_as_foreground_window(&self) {
1027        let Some(handle) = self.window.handle.hwnd() else {
1028            return;
1029        };
1030        unsafe {
1031            let _ = windows::Win32::UI::WindowsAndMessaging::SetForegroundWindow(
1032                windows::Win32::Foundation::HWND(handle.cast()),
1033            );
1034        }
1035    }
1036
1037    fn save_position_and_size(&self) {
1038        let Some(tray) = self.tray.get() else {
1039            return;
1040        };
1041        let pos = self.window.position();
1042        let size = self.window.size();
1043        let placement = window_placement(&self.window).unwrap_or(WindowPlacement::Minimized);
1044        let maximized = placement == WindowPlacement::Maximized;
1045
1046        tracing::trace!(
1047            position =? pos,
1048            size =? size,
1049            ?placement,
1050            "Config window resized or moved"
1051        );
1052        if placement == WindowPlacement::Minimized {
1053            return;
1054        }
1055
1056        tray.settings().update(|prev| UiSettings {
1057            config_window: if maximized {
1058                // Don't save size and position of maximized window:
1059                ConfigWindowInfo {
1060                    maximized,
1061                    ..prev.config_window
1062                }
1063            } else {
1064                ConfigWindowInfo {
1065                    position: Some(pos),
1066                    size,
1067                    maximized,
1068                }
1069            },
1070            ..prev.clone()
1071        });
1072    }
1073    fn on_resize_end(&self) {
1074        self.save_position_and_size();
1075    }
1076    fn on_move(&self) {
1077        self.save_position_and_size();
1078    }
1079    fn on_close(&self) {
1080        self.is_closed.set(true);
1081        if let Some(background) = &*self.background_thread.borrow() {
1082            background.should_exit.store(true, AtomicOrdering::Release);
1083        }
1084    }
1085    fn on_window_min_max_info(&self, data: &nwg::EventData) {
1086        let nwg::EventData::OnMinMaxInfo(info) = data else {
1087            return;
1088        };
1089        let (width, height) = Self::MIN_SIZE;
1090        info.set_min_size(width, height);
1091    }
1092}
1093/// Handle Sidebar Events
1094impl ConfigWindow {
1095    fn on_apply_filters(&self) {
1096        let Some(tray) = self.tray.get() else {
1097            return;
1098        };
1099        tray.apply_filters();
1100    }
1101    fn on_refresh_info(&self) {
1102        self.gather_window_info();
1103    }
1104    fn on_export_filters(&self) {
1105        let dialog = if let Some(dialog) = self.export_dialog.get() {
1106            dialog
1107        } else {
1108            let mut dialog = nwg::FileDialog::default();
1109            if let Err(e) = nwg::FileDialog::builder()
1110                .title("Export Virtual Desktop Manager Rules / Filters")
1111                .action(nwg::FileDialogAction::Save)
1112                .filters("JSON filters(*.json)|Xml legacy filters(*.xml;*.txt)|Any filter file(*.json;*.xml;*.txt)|All files(*)")
1113                .build(&mut dialog)
1114            {
1115                tracing::error!(error = e.to_string(), "Failed to create export dialog");
1116                return;
1117            }
1118            self.export_dialog.get_or_init(|| dialog)
1119        };
1120        if !dialog.run(Some(self.window.handle)) {
1121            return;
1122        }
1123        let Ok(mut selected) = dialog
1124            .get_selected_item()
1125            .map(PathBuf::from)
1126            .inspect_err(|e| {
1127                tracing::error!(
1128                    error = e.to_string(),
1129                    "Failed to get selected item from export dialog"
1130                );
1131            })
1132        else {
1133            return;
1134        };
1135
1136        // The file dialog should have asked about overwriting existing file.
1137        let mut allow_overwrite = true;
1138
1139        let is_legacy = if let Some(ext) = selected.extension() {
1140            ext.eq_ignore_ascii_case("xml") || ext.eq_ignore_ascii_case("txt")
1141        } else {
1142            selected.set_extension("json");
1143            allow_overwrite = false; // <- Since we change the path the dialog would not have warned about overwrite
1144            false
1145        };
1146        let Some(data) = (if is_legacy {
1147            #[cfg(feature = "persist_filters_xml")]
1148            {
1149                let filters = self.loaded_filters.borrow().clone();
1150                WindowFilter::serialize_to_xml(filters.as_deref().unwrap_or_default())
1151                    .inspect_err(|e| {
1152                        nwg::error_message(
1153                            "Virtual Desktop Manager - Export error",
1154                            &format!("Failed to convert filters to legacy XML format:\n{e}"),
1155                        );
1156                    })
1157                    .ok()
1158            }
1159            #[cfg(not(feature = "persist_filters_xml"))]
1160            {
1161                nwg::error_message(
1162                    "Virtual Desktop Manager - Export error",
1163                    "This program was compiled without support for legacy XML filters/rules. \
1164                    Recompile the program from source with the \"persist_filters_xml\" feature \
1165                    in order to support exporting such filter files.",
1166                );
1167                None
1168            }
1169        } else {
1170            #[cfg(feature = "persist_filters")]
1171            {
1172                let exported = ExportedWindowFilters {
1173                    filters: self
1174                        .loaded_filters
1175                        .borrow()
1176                        .clone()
1177                        .unwrap_or_default()
1178                        .to_vec(),
1179                    ..Default::default()
1180                };
1181                serde_json::to_string_pretty(&exported)
1182                    .inspect_err(|e| {
1183                        nwg::error_message(
1184                            "Virtual Desktop Manager - Export error",
1185                            &format!("Failed to convert filters to JSON:\n{e}"),
1186                        );
1187                    })
1188                    .ok()
1189            }
1190            #[cfg(not(feature = "persist_filters"))]
1191            {
1192                nwg::error_message(
1193                    "Virtual Desktop Manager - Export error",
1194                    "This program was compiled without support for JSON filters/rules. \
1195                    Recompile the program from source with the \"persist_filters\" feature \
1196                    in order to support exporting such filter files.",
1197                );
1198                None
1199            }
1200        }) else {
1201            return;
1202        };
1203        let Ok(mut file) = OpenOptions::new()
1204            .create(true)
1205            .create_new(!allow_overwrite)
1206            .write(true)
1207            .truncate(true)
1208            .open(selected.as_path())
1209            .inspect_err(|e| {
1210                nwg::error_message(
1211                    "Virtual Desktop Manager - Export error",
1212                    &format!("Failed to create file at \"{}\":\n{e}", selected.display()),
1213                );
1214            })
1215        else {
1216            return;
1217        };
1218        if let Err(e) = file.write_all(data.as_bytes()) {
1219            nwg::error_message(
1220                "Virtual Desktop Manager - Export error",
1221                &format!(
1222                    "Failed to write data to file at \"{}\":\n{e}",
1223                    selected.display()
1224                ),
1225            );
1226        }
1227    }
1228    fn on_import_filters(&self) {
1229        let dialog = if let Some(dialog) = self.import_dialog.get() {
1230            dialog
1231        } else {
1232            let mut dialog = nwg::FileDialog::default();
1233            if let Err(e) = nwg::FileDialog::builder()
1234                .title("Import Virtual Desktop Manager Rules / Filters")
1235                .action(nwg::FileDialogAction::Open)
1236                .filters("Any filter file(*.json;*.xml;*.txt)|JSON filters(*.json)|Xml legacy filters(*.xml;*.txt)|All files(*)")
1237                .build(&mut dialog)
1238            {
1239                tracing::error!(error = e.to_string(), "Failed to create import dialog");
1240                return;
1241            }
1242            self.import_dialog.get_or_init(|| dialog)
1243        };
1244        if !dialog.run(Some(self.window.handle)) {
1245            return;
1246        }
1247        let Ok(selected) = dialog
1248            .get_selected_item()
1249            .map(PathBuf::from)
1250            .inspect_err(|e| {
1251                tracing::error!(
1252                    error = e.to_string(),
1253                    "Failed to get selected item from import dialog"
1254                );
1255            })
1256        else {
1257            return;
1258        };
1259        let data = match std::fs::read_to_string(selected.as_path()) {
1260            Ok(v) => v,
1261            Err(e) => {
1262                nwg::error_message(
1263                    "Virtual Desktop Manager - Import error",
1264                    &format!(
1265                        "Error when reading file with filter/rule at \"{}\":\n\n{e}",
1266                        selected.display()
1267                    ),
1268                );
1269                return;
1270            }
1271        };
1272        let is_legacy = selected
1273            .extension()
1274            .is_some_and(|ext| ext.eq_ignore_ascii_case("xml") || ext.eq_ignore_ascii_case("txt"));
1275
1276        let Some(imported) = (if is_legacy {
1277            #[cfg(feature = "persist_filters_xml")]
1278            {
1279                WindowFilter::deserialize_from_xml(&data)
1280                    .inspect_err(|e| {
1281                        nwg::error_message(
1282                            "Virtual Desktop Manager - Import error",
1283                            &format!("Failed to parse legacy XML filters/rules:\n{e}"),
1284                        );
1285                    })
1286                    .ok()
1287            }
1288            #[cfg(not(feature = "persist_filters_xml"))]
1289            {
1290                nwg::error_message(
1291                    "Virtual Desktop Manager - Import error",
1292                    "This program was compiled without support for legacy XML filters/rules. \
1293                    Recompile the program from source with the \"persist_filters_xml\" feature \
1294                    in order to support such filter files.",
1295                );
1296                None
1297            }
1298        } else {
1299            #[cfg(feature = "persist_filters")]
1300            {
1301                let mut deserializer = serde_json::Deserializer::from_str(&data);
1302                let result: Result<ExportedWindowFilters, _> = {
1303                    #[cfg(not(feature = "serde_path_to_error"))]
1304                    {
1305                        serde::Deserialize::deserialize(&mut deserializer)
1306                    }
1307                    #[cfg(feature = "serde_path_to_error")]
1308                    {
1309                        serde_path_to_error::deserialize(&mut deserializer)
1310                    }
1311                };
1312                result
1313                    .inspect_err(|e| {
1314                        nwg::error_message(
1315                            "Virtual Desktop Manager - Import error",
1316                            &format!("Failed to parse JSON filters/rules:\n{e}"),
1317                        );
1318                    })
1319                    .ok()
1320                    .map(|info| info.migrate_and_get_filters())
1321            }
1322            #[cfg(not(feature = "persist_filters"))]
1323            {
1324                nwg::error_message(
1325                    "Virtual Desktop Manager - Import error",
1326                    "This program was compiled without support for JSON filters/rules. \
1327                    Recompile the program from source with the \"persist_filters\" feature \
1328                    in order to support such filter files.",
1329                );
1330                None
1331            }
1332        }) else {
1333            return;
1334        };
1335        let Some(tray) = self.tray.get() else {
1336            return;
1337        };
1338        tray.settings().update(|prev| UiSettings {
1339            filters: prev.filters.iter().cloned().chain(imported).collect(),
1340            ..prev.clone()
1341        });
1342    }
1343}
1344/// Methods related to "Configure filter" tab.
1345impl ConfigWindow {
1346    fn on_list_view_item_activated(&self, data: &nwg::EventData) {
1347        tracing::debug!(?data, "ConfigWindow::on_list_view_item_activated");
1348        let &nwg::EventData::OnListViewItemIndex { row_index, .. } = data else {
1349            return;
1350        };
1351        let group = list_view_item_get_group_id(&self.data_view, row_index);
1352        if group != Self::GROUP_FILTERS {
1353            return;
1354        }
1355        let Some(filter_index_item) =
1356            self.data_view
1357                .item(row_index, Self::COLUMN_FILTERS_INDEX, 10)
1358        else {
1359            return;
1360        };
1361        let Ok(filter_index) = filter_index_item.text.parse::<usize>() else {
1362            return;
1363        };
1364
1365        self.set_selected_filter_index(Some(filter_index - 1));
1366    }
1367    fn on_select_filter_index_changed(&self) {
1368        let wanted = self.get_selected_filter_index();
1369        if self.selected_filter_index.get() != wanted {
1370            self.set_selected_filter_index(wanted);
1371        }
1372    }
1373    fn highlight_selected_filter_in_list(&self) {
1374        let selected = self.get_selected_filter_index();
1375        for row_index in 0..self.data_view.len() {
1376            let group = list_view_item_get_group_id(&self.data_view, row_index);
1377            if group != Self::GROUP_FILTERS {
1378                continue;
1379            }
1380            if let Some(filter_ix) = self
1381                .data_view
1382                .item(row_index, Self::COLUMN_FILTERS_INDEX, 10)
1383            {
1384                if let Ok(filter_ix) = filter_ix.text.parse::<usize>() {
1385                    // UI has one-based index:
1386                    let filter_ix = filter_ix - 1;
1387                    if Some(filter_ix) == selected {
1388                        self.data_view.select_item(row_index, true);
1389                        continue;
1390                    }
1391                }
1392            }
1393            self.data_view.select_item(row_index, false);
1394        }
1395    }
1396    fn set_selected_filter_index(&self, index: Option<usize>) {
1397        self.selected_filter_index.set(index);
1398        let loaded_filters_len = self
1399            .loaded_filters
1400            .borrow()
1401            .as_deref()
1402            .unwrap_or_default()
1403            .len() as i64;
1404        self.filter_select_index
1405            .set_data(nwg::NumberSelectData::Int {
1406                value: index.map(|v| v + 1).unwrap_or(0) as i64,
1407                step: 1,
1408                max: loaded_filters_len,
1409                // 0 means no filter selected:
1410                min: 0,
1411            });
1412        self.highlight_selected_filter_in_list();
1413        self.set_filter_config_enabled(index.is_some());
1414        if let Some(index) = index {
1415            let loaded_filters = self.loaded_filters.borrow().clone();
1416            let loaded_filters = loaded_filters.as_deref().unwrap_or_default();
1417            let Some(filter) = loaded_filters.get(index) else {
1418                self.set_selected_filter_index(None);
1419                return;
1420            };
1421            self.set_filter_config_for_sidebar(filter);
1422        } else {
1423            self.set_filter_config_for_sidebar(&Default::default());
1424        }
1425    }
1426
1427    fn set_filter_config_enabled(&self, enabled: bool) {
1428        self.filter_window_index_lower.set_enabled(enabled);
1429        self.filter_window_index_lower_checkbox.set_enabled(enabled);
1430        self.filter_window_index_upper.set_enabled(enabled);
1431        self.filter_window_index_upper_checkbox.set_enabled(enabled);
1432
1433        self.filter_desktop_index_lower.set_enabled(enabled);
1434        self.filter_desktop_index_lower_checkbox
1435            .set_enabled(enabled);
1436        self.filter_desktop_index_upper.set_enabled(enabled);
1437        self.filter_desktop_index_upper_checkbox
1438            .set_enabled(enabled);
1439
1440        self.filter_title.set_enabled(enabled);
1441
1442        self.filter_process.set_enabled(enabled);
1443
1444        self.filter_action.set_enabled(enabled);
1445
1446        self.filter_target_desktop.set_enabled(enabled);
1447    }
1448    fn set_filter_config_for_sidebar(&self, filter: &WindowFilter) {
1449        fn set_checked(check_box: &nwg::CheckBox, checked: bool) {
1450            check_box.set_check_state(if checked {
1451                nwg::CheckBoxState::Checked
1452            } else {
1453                nwg::CheckBoxState::Unchecked
1454            });
1455        }
1456        fn set_text(text_box: &nwg::TextBox, new_text: &str) {
1457            let new_text = new_text
1458                .chars()
1459                .flat_map(|c| {
1460                    [
1461                        Some('\r').filter(|_| c == '\n'),
1462                        Some(c).filter(|&c| c != '\r'),
1463                    ]
1464                })
1465                .flatten()
1466                .collect::<String>();
1467            if text_box.text() != new_text {
1468                text_box.set_text(&new_text);
1469            }
1470        }
1471
1472        // Window Index - Lower Bound:
1473        set_checked(
1474            &self.filter_window_index_lower_checkbox,
1475            filter.window_index.lower_bound.is_some(),
1476        );
1477        self.filter_window_index_lower
1478            .set_data(nwg::NumberSelectData::Int {
1479                value: filter
1480                    .window_index
1481                    .lower_bound
1482                    .unwrap_or_default()
1483                    .saturating_add(1)
1484                    .max(1),
1485                step: 1,
1486                max: i64::MAX,
1487                min: 1,
1488            });
1489
1490        // Window Index - Upper Bound:
1491        set_checked(
1492            &self.filter_window_index_upper_checkbox,
1493            filter.window_index.upper_bound.is_some(),
1494        );
1495        self.filter_window_index_upper
1496            .set_data(nwg::NumberSelectData::Int {
1497                value: filter
1498                    .window_index
1499                    .upper_bound
1500                    .unwrap_or_default()
1501                    .saturating_add(1)
1502                    .max(1),
1503                step: 1,
1504                max: i64::MAX,
1505                min: 1,
1506            });
1507
1508        // Desktop Index - Lower Bound:
1509        set_checked(
1510            &self.filter_desktop_index_lower_checkbox,
1511            filter.desktop_index.lower_bound.is_some(),
1512        );
1513        self.filter_desktop_index_lower
1514            .set_data(nwg::NumberSelectData::Int {
1515                value: filter
1516                    .desktop_index
1517                    .lower_bound
1518                    .unwrap_or_default()
1519                    .saturating_add(1)
1520                    .max(1),
1521                step: 1,
1522                max: i64::MAX,
1523                min: 1,
1524            });
1525
1526        // Desktop Index - Upper Bound:
1527        set_checked(
1528            &self.filter_desktop_index_upper_checkbox,
1529            filter.desktop_index.upper_bound.is_some(),
1530        );
1531        self.filter_desktop_index_upper
1532            .set_data(nwg::NumberSelectData::Int {
1533                value: filter
1534                    .desktop_index
1535                    .upper_bound
1536                    .unwrap_or_default()
1537                    .saturating_add(1)
1538                    .max(1),
1539                step: 1,
1540                max: i64::MAX,
1541                min: 1,
1542            });
1543
1544        // Window Title:
1545        set_text(&self.filter_title, filter.window_title.pattern());
1546
1547        // Process Name:
1548        set_text(&self.filter_process, filter.process_name.pattern());
1549
1550        // Action:
1551        {
1552            let index = self
1553                .filter_action
1554                .collection()
1555                .iter()
1556                .position(|&item| item == filter.action);
1557            self.filter_action.set_selection(index);
1558        }
1559
1560        // Target Desktop:
1561        self.filter_target_desktop
1562            .set_data(nwg::NumberSelectData::Int {
1563                value: filter.target_desktop.saturating_add(1).max(1),
1564                step: 1,
1565                max: i64::MAX,
1566                min: 1,
1567            });
1568    }
1569    fn get_filter_config_for_sidebar(&self) -> Option<WindowFilter> {
1570        Some(WindowFilter {
1571            window_index: {
1572                IntegerRange {
1573                    lower_bound: if self.filter_window_index_lower_checkbox.check_state()
1574                        != nwg::CheckBoxState::Checked
1575                    {
1576                        None
1577                    } else if let nwg::NumberSelectData::Int { value, .. } =
1578                        self.filter_window_index_lower.data()
1579                    {
1580                        Some(value.saturating_sub(1).max(0))
1581                    } else {
1582                        return None;
1583                    },
1584                    upper_bound: if self.filter_window_index_upper_checkbox.check_state()
1585                        != nwg::CheckBoxState::Checked
1586                    {
1587                        None
1588                    } else if let nwg::NumberSelectData::Int { value, .. } =
1589                        self.filter_window_index_upper.data()
1590                    {
1591                        Some(value.saturating_sub(1).max(0))
1592                    } else {
1593                        return None;
1594                    },
1595                }
1596            },
1597            desktop_index: {
1598                IntegerRange {
1599                    lower_bound: if self.filter_desktop_index_lower_checkbox.check_state()
1600                        != nwg::CheckBoxState::Checked
1601                    {
1602                        None
1603                    } else if let nwg::NumberSelectData::Int { value, .. } =
1604                        self.filter_desktop_index_lower.data()
1605                    {
1606                        Some(value.saturating_sub(1).max(0))
1607                    } else {
1608                        return None;
1609                    },
1610                    upper_bound: if self.filter_desktop_index_upper_checkbox.check_state()
1611                        != nwg::CheckBoxState::Checked
1612                    {
1613                        None
1614                    } else if let nwg::NumberSelectData::Int { value, .. } =
1615                        self.filter_desktop_index_upper.data()
1616                    {
1617                        Some(value.saturating_sub(1).max(0))
1618                    } else {
1619                        return None;
1620                    },
1621                }
1622            },
1623            window_title: TextPattern::new(Arc::from(self.filter_title.text().replace('\r', ""))),
1624            process_name: TextPattern::new(Arc::from(self.filter_process.text().replace('\r', ""))),
1625            action: 'action: {
1626                let Some(selected) = self.filter_action.selection() else {
1627                    break 'action FilterAction::default();
1628                };
1629                self.filter_action
1630                    .collection()
1631                    .get(selected)
1632                    .copied()
1633                    .unwrap_or_default()
1634            },
1635            target_desktop: if let nwg::NumberSelectData::Int { value, .. } =
1636                self.filter_target_desktop.data()
1637            {
1638                value.saturating_sub(1).max(0)
1639            } else {
1640                return None;
1641            },
1642        })
1643    }
1644
1645    fn get_selected_filter_index(&self) -> Option<usize> {
1646        let nwg::NumberSelectData::Int { value, .. } = self.filter_select_index.data() else {
1647            return None;
1648        };
1649        if value < 1 {
1650            return None;
1651        }
1652        Some((value - 1) as usize)
1653    }
1654
1655    /// The user changed an options in the "Configure filter" panel.
1656    fn on_filter_config_ui_changed(&self) {
1657        let Some(index) = self.get_selected_filter_index() else {
1658            return;
1659        };
1660        let Some(tray) = self.tray.get() else {
1661            return;
1662        };
1663        let Some(new_filter) = self.get_filter_config_for_sidebar() else {
1664            return;
1665        };
1666
1667        tray.settings().update(|prev| UiSettings {
1668            filters: prev
1669                .filters
1670                .iter()
1671                .cloned()
1672                .enumerate()
1673                .map(move |(ix, filter)| {
1674                    if ix == index {
1675                        new_filter.clone()
1676                    } else {
1677                        filter
1678                    }
1679                })
1680                .collect(),
1681            ..prev.clone()
1682        });
1683    }
1684    fn on_create_filter(&self) {
1685        let Some(tray) = self.tray.get() else {
1686            return;
1687        };
1688        tray.settings().update(|prev| UiSettings {
1689            filters: prev
1690                .filters
1691                .iter()
1692                .cloned()
1693                .chain(Some(WindowFilter::default()))
1694                .collect(),
1695            ..prev.clone()
1696        });
1697    }
1698    fn on_delete_current_filter(&self) {
1699        let Some(index) = self.get_selected_filter_index() else {
1700            return;
1701        };
1702        let Some(tray) = self.tray.get() else {
1703            return;
1704        };
1705        tray.settings().update(|prev| UiSettings {
1706            filters: prev
1707                .filters
1708                .iter()
1709                .enumerate()
1710                .filter(|&(ix, _)| ix != index)
1711                .map(|(_, filter)| filter.clone())
1712                .collect(),
1713            ..prev.clone()
1714        });
1715    }
1716    fn populate_filter_list(&self, filters: &Arc<[WindowFilter]>) {
1717        let prev_filters = self.loaded_filters.borrow().clone();
1718        let prev_filters = prev_filters.as_deref().unwrap_or_default();
1719        let mut indexes_to_skip = Vec::with_capacity(prev_filters.len());
1720
1721        tracing::trace!(
1722            old_filters_count = prev_filters.len(),
1723            new_filters_count = filters.len(),
1724            "ConfigWindow::populate_filter_list"
1725        );
1726
1727        fn get_filter_columns(filter_index: usize, filter: &WindowFilter) -> [String; 6] {
1728            let WindowFilter {
1729                window_index,
1730                desktop_index,
1731                window_title,
1732                process_name,
1733                action: _,
1734                target_desktop: _,
1735            } = filter;
1736
1737            [
1738                window_index.one_based_indexes().to_string(),
1739                desktop_index.one_based_indexes().to_string(),
1740                window_title.display_escaped_newline_glob().to_string(),
1741                process_name.display_escaped_newline_glob().to_string(),
1742                filter_index.saturating_add(1).to_string(),
1743                filter.display_target_desktop().to_string(),
1744            ]
1745        }
1746
1747        // Update existing filter items:
1748        for ix in (0..self.data_view.len()).rev() {
1749            let group = list_view_item_get_group_id(&self.data_view, ix);
1750            if group != Self::GROUP_FILTERS {
1751                continue;
1752            }
1753            if let Some(filter_ix) = self.data_view.item(ix, Self::COLUMN_FILTERS_INDEX, 10) {
1754                if let Ok(filter_ix) = filter_ix.text.parse::<usize>() {
1755                    // UI has one-based index:
1756                    let filter_ix = filter_ix - 1;
1757                    if let Some(prev) = prev_filters.get(filter_ix) {
1758                        if let Some(new) = filters.get(filter_ix) {
1759                            if prev != new {
1760                                let info = get_filter_columns(filter_ix, new);
1761                                for (column_ix, text) in info.into_iter().enumerate() {
1762                                    self.data_view.update_item(
1763                                        ix,
1764                                        nwg::InsertListViewItem {
1765                                            index: Some(ix as _),
1766                                            column_index: column_ix as _,
1767                                            text: Some(text),
1768                                            image: None,
1769                                        },
1770                                    );
1771                                }
1772                            }
1773                            indexes_to_skip.push(filter_ix);
1774                            continue;
1775                        }
1776                    }
1777                }
1778            }
1779            self.data_view.remove_item(ix);
1780        }
1781        tracing::trace!(
1782            updated_filter_indexes = ?indexes_to_skip,
1783            "ConfigWindow::populate_filter_list updated {} items and will create {}",
1784            indexes_to_skip.len(),
1785            filters.len() - indexes_to_skip.len()
1786        );
1787
1788        // Create new filter items:
1789        for (filter_index, filter) in filters.iter().enumerate() {
1790            if indexes_to_skip.contains(&filter_index) {
1791                continue;
1792            }
1793            let info = get_filter_columns(filter_index, filter);
1794            self.data_view.insert_items_row(None, &info);
1795            list_view_item_set_group_id(
1796                &self.data_view,
1797                self.data_view.len().saturating_sub(1),
1798                Some(Self::GROUP_FILTERS),
1799            );
1800        }
1801        self.loaded_filters.replace(Some(filters.clone()));
1802        self.is_data_sorted.set(false);
1803
1804        // Windows might now be affected by different filters:
1805        self.update_window_infos();
1806
1807        self.resort_items();
1808
1809        // Update sidebar with config for selected filter:
1810        let selected_filter = if let Some(prev_selected) = self.get_selected_filter_index() {
1811            if prev_selected >= filters.len() {
1812                // Prev selection was removed, select last remaining:
1813                Some(filters.len().saturating_sub(1))
1814            } else if !prev_filters.is_empty() && filters.len() > prev_filters.len() {
1815                // New items were added to an existing list, select newest item:
1816                Some(filters.len().saturating_sub(1))
1817            } else {
1818                Some(prev_selected)
1819            }
1820        } else {
1821            None
1822        };
1823        self.set_selected_filter_index(selected_filter);
1824    }
1825    fn sync_filter_from_settings(&self, settings: Option<&Arc<UiSettings>>) {
1826        let settings_owned;
1827        let settings = match settings {
1828            Some(s) => s,
1829            None => {
1830                let Some(tray) = self.tray.get() else {
1831                    return;
1832                };
1833                settings_owned = tray.settings().get();
1834                &settings_owned
1835            }
1836        };
1837        self.populate_filter_list(&settings.filters);
1838    }
1839}
1840/// Methods related to "Program settings" tab.
1841impl ConfigWindow {
1842    fn on_settings_ui_changed(&self) {
1843        let auto_start = self
1844            .settings_auto_start
1845            .selection()
1846            .and_then(|ix| self.settings_auto_start.collection().get(ix).copied())
1847            .unwrap_or_default();
1848        let tray_icon_type = self
1849            .settings_tray_icon
1850            .selection()
1851            .and_then(|ix| self.settings_tray_icon.collection().get(ix).copied())
1852            .unwrap_or_default();
1853        let quick_switch_menu = self
1854            .settings_quick_menu
1855            .selection()
1856            .and_then(|ix| self.settings_quick_menu.collection().get(ix).copied())
1857            .unwrap_or_default();
1858        let left_click = self
1859            .settings_left_click
1860            .selection()
1861            .and_then(|ix| self.settings_left_click.collection().get(ix).copied())
1862            .unwrap_or_default();
1863        let middle_click = self
1864            .settings_middle_click
1865            .selection()
1866            .and_then(|ix| self.settings_middle_click.collection().get(ix).copied())
1867            .unwrap_or_default();
1868        let mut quick_shortcuts_count = 0;
1869        let mut invalid_quick_shortcut_target = false;
1870        let quick_switch_menu_shortcuts = Arc::new(
1871            self.settings_quick_menu_shortcuts
1872                .text()
1873                .split('\n')
1874                .filter_map(|text| {
1875                    // remove \r at end of line (might be a letter after it if
1876                    // the cursor was placed between the \r and \n):
1877                    let text = text.trim_end_matches('\r');
1878                    if text.contains('\r') {
1879                        // cursor was likely after the \r and wrote something,
1880                        // move the cursor back
1881                        invalid_quick_shortcut_target = true;
1882                    }
1883                    let text = text.replace('\r', "");
1884                    if text.is_empty() {
1885                        return None;
1886                    }
1887                    let (target, key): (String, String) =
1888                        text.chars().partition(char::is_ascii_digit);
1889                    let target = if target.is_empty() {
1890                        // No target number
1891                        invalid_quick_shortcut_target = true;
1892                        0
1893                    } else {
1894                        u32::try_from(
1895                            target
1896                                .parse::<i64>()
1897                                .unwrap_or_else(|_| {
1898                                    // Invalid target, maybe trailing non-digits
1899                                    invalid_quick_shortcut_target = true;
1900                                    0
1901                                })
1902                                .abs(),
1903                        )
1904                        .unwrap_or_else(|_| {
1905                            // Too many digits:
1906                            invalid_quick_shortcut_target = true;
1907                            u32::MAX
1908                        })
1909                    };
1910                    Some((key, target))
1911                })
1912                .inspect(|_| {
1913                    quick_shortcuts_count += 1;
1914                })
1915                .collect::<BTreeMap<_, _>>(),
1916        );
1917        let quick_switch_hotkey = Arc::<str>::from(
1918            self.settings_quick_menu_hotkey
1919                .text()
1920                .trim_matches(['\n', '\r']),
1921        );
1922        let open_menu_at_mouse_pos_hotkey = Arc::<str>::from(
1923            self.settings_open_menu_at_mouse_pos_hotkey
1924                .text()
1925                .trim_matches(['\n', '\r']),
1926        );
1927        tracing::debug!(
1928            settings_start_as_admin = ?self.settings_start_as_admin.check_state(),
1929            settings_prevent_flashing_windows = ?self.settings_prevent_flashing_windows.check_state(),
1930            settings_smooth_switch_desktop = ?self.settings_smooth_switch_desktop.check_state(),
1931            ?auto_start,
1932            ?tray_icon_type,
1933            ?quick_switch_menu,
1934            ?quick_switch_menu_shortcuts,
1935            settings_quick_menu_shortcuts_in_submenus =? self.settings_quick_menu_shortcuts_in_submenus.check_state(),
1936            ?quick_switch_hotkey,
1937            ?left_click,
1938            ?middle_click,
1939            ?open_menu_at_mouse_pos_hotkey,
1940            "ConfigWindow::on_settings_ui_changed"
1941        );
1942        if invalid_quick_shortcut_target
1943            || quick_shortcuts_count != quick_switch_menu_shortcuts.len()
1944        {
1945            // Had duplicates
1946            tracing::debug!(
1947                "Invalid numbers or duplicated items in quick switch shortcuts field, \
1948                restoring to current settings value"
1949            );
1950            self.sync_quick_shortcuts_from(&quick_switch_menu_shortcuts);
1951        }
1952        let Some(tray) = self.tray.get() else {
1953            return;
1954        };
1955        tray.settings().update(|prev| UiSettings {
1956            request_admin_at_startup: self.settings_start_as_admin.check_state()
1957                == nwg::CheckBoxState::Checked,
1958            auto_start,
1959            stop_flashing_windows_after_applying_filter: self
1960                .settings_prevent_flashing_windows
1961                .check_state()
1962                == nwg::CheckBoxState::Checked,
1963            smooth_switch_desktops: self.settings_smooth_switch_desktop.check_state()
1964                == nwg::CheckBoxState::Checked,
1965            tray_icon_type,
1966            quick_switch_menu,
1967            quick_switch_menu_shortcuts,
1968            quick_switch_menu_shortcuts_only_in_root: self
1969                .settings_quick_menu_shortcuts_in_submenus
1970                .check_state()
1971                != nwg::CheckBoxState::Checked,
1972            quick_switch_hotkey,
1973            left_click,
1974            middle_click,
1975            open_menu_at_mouse_pos_hotkey,
1976            ..prev.clone()
1977        });
1978    }
1979    fn sync_program_options_from_settings(&self, settings: Option<&Arc<UiSettings>>) {
1980        let settings_owned;
1981        let settings = match settings {
1982            Some(s) => s,
1983            None => {
1984                let Some(tray) = self.tray.get() else {
1985                    return;
1986                };
1987                settings_owned = tray.settings().get();
1988                &settings_owned
1989            }
1990        };
1991        fn set_checked(check_box: &nwg::CheckBox, checked: bool) {
1992            check_box.set_check_state(if checked {
1993                nwg::CheckBoxState::Checked
1994            } else {
1995                nwg::CheckBoxState::Unchecked
1996            });
1997        }
1998        set_checked(
1999            &self.settings_start_as_admin,
2000            settings.request_admin_at_startup,
2001        );
2002        {
2003            let index = self
2004                .settings_auto_start
2005                .collection()
2006                .iter()
2007                .position(|&item| item == settings.auto_start);
2008            self.settings_auto_start.set_selection(index);
2009        }
2010        set_checked(
2011            &self.settings_prevent_flashing_windows,
2012            settings.stop_flashing_windows_after_applying_filter,
2013        );
2014        set_checked(
2015            &self.settings_smooth_switch_desktop,
2016            settings.smooth_switch_desktops,
2017        );
2018        {
2019            let index = self
2020                .settings_tray_icon
2021                .collection()
2022                .iter()
2023                .position(|&item| item == settings.tray_icon_type);
2024            self.settings_tray_icon.set_selection(index);
2025        }
2026        {
2027            let index = self
2028                .settings_quick_menu
2029                .collection()
2030                .iter()
2031                .position(|&item| item == settings.quick_switch_menu);
2032            self.settings_quick_menu.set_selection(index);
2033        }
2034        self.sync_quick_shortcuts_from(&settings.quick_switch_menu_shortcuts);
2035        set_checked(
2036            &self.settings_quick_menu_shortcuts_in_submenus,
2037            !settings.quick_switch_menu_shortcuts_only_in_root,
2038        );
2039        {
2040            let new_text = &*settings.quick_switch_hotkey;
2041            if new_text != self.settings_quick_menu_hotkey.text() {
2042                self.settings_quick_menu_hotkey.set_text(new_text);
2043            }
2044            self.settings_quick_menu_hotkey_error.set_text(&{
2045                if settings.quick_switch_hotkey.is_empty() {
2046                    "Hotkey disabled".to_owned()
2047                } else {
2048                    #[cfg(feature = "global_hotkey")]
2049                    {
2050                        match global_hotkey::hotkey::HotKey::from_str(&settings.quick_switch_hotkey)
2051                        {
2052                            Ok(_) => "Valid hotkey".to_owned(),
2053                            Err(e) => format!("Invalid hotkey: {e}"),
2054                        }
2055                    }
2056                    #[cfg(not(feature = "global_hotkey"))]
2057                    {
2058                        "Compiled without hotkey support".to_owned()
2059                    }
2060                }
2061            });
2062        }
2063        {
2064            let index = self
2065                .settings_left_click
2066                .collection()
2067                .iter()
2068                .position(|&item| item == settings.left_click);
2069            self.settings_left_click.set_selection(index);
2070        }
2071        {
2072            let index = self
2073                .settings_middle_click
2074                .collection()
2075                .iter()
2076                .position(|&item| item == settings.middle_click);
2077            self.settings_middle_click.set_selection(index);
2078        }
2079        {
2080            let new_text = &*settings.open_menu_at_mouse_pos_hotkey;
2081            if new_text != self.settings_open_menu_at_mouse_pos_hotkey.text() {
2082                self.settings_open_menu_at_mouse_pos_hotkey
2083                    .set_text(new_text);
2084            }
2085            self.settings_open_menu_at_mouse_pos_hotkey_error
2086                .set_text(&{
2087                    if settings.open_menu_at_mouse_pos_hotkey.is_empty() {
2088                        "Hotkey disabled".to_owned()
2089                    } else {
2090                        #[cfg(feature = "global_hotkey")]
2091                        {
2092                            match global_hotkey::hotkey::HotKey::from_str(
2093                                &settings.open_menu_at_mouse_pos_hotkey,
2094                            ) {
2095                                Ok(_) => "Valid hotkey".to_owned(),
2096                                Err(e) => format!("Invalid hotkey: {e}"),
2097                            }
2098                        }
2099                        #[cfg(not(feature = "global_hotkey"))]
2100                        {
2101                            "Compiled without hotkey support".to_owned()
2102                        }
2103                    }
2104                });
2105        }
2106    }
2107    fn sync_quick_shortcuts_from(&self, shortcuts: &BTreeMap<String, u32>) {
2108        let selection = self.settings_quick_menu_shortcuts.selection();
2109        let text = shortcuts.iter().fold(
2110            String::with_capacity(shortcuts.len() * 4),
2111            |mut f, (mut key, target)| {
2112                // Don't write any extra newlines (could cause issues if they don't have the extra \r):
2113                let newlines = ['\r', '\n'];
2114                let new_key;
2115                if key.contains(newlines) {
2116                    new_key = key.replace(newlines, "");
2117                    key = &new_key;
2118                }
2119
2120                use std::fmt::Write;
2121                write!(f, "{}{}\r\n", key, target)
2122                    .expect("should succeed at writing to in-memory string");
2123                f
2124            },
2125        );
2126        self.settings_quick_menu_shortcuts.set_text(&text);
2127        let mut selection =
2128            selection.start.min(text.len() as u32)..selection.end.min(text.len() as u32);
2129
2130        // Previous cursor position might now be in the middle of a \r\n, so check for that:
2131        let Some(selected_and_prev) = text
2132            .as_bytes()
2133            .get((selection.start as usize).saturating_sub(1)..(selection.end as usize))
2134        else {
2135            tracing::warn!(
2136                ?selection,
2137                text,
2138                "Selection was over invalid characters so can't update it"
2139            );
2140            return;
2141        };
2142        tracing::debug!(
2143            selected_and_prev =? String::from_utf8_lossy(selected_and_prev),
2144            range =? selection,
2145            "Updating Quick switch shortcut text box selection"
2146        );
2147        if selected_and_prev.starts_with(b"\r") {
2148            selection.start = selection.start.saturating_sub(1);
2149            if selected_and_prev.len() == 1 {
2150                selection.end = selection.end.saturating_sub(1);
2151            }
2152        }
2153        if selected_and_prev.len() > 1 && selected_and_prev.ends_with(b"\r") {
2154            selection.end = selection.end.saturating_add(1).min(text.len() as u32);
2155        }
2156
2157        self.settings_quick_menu_shortcuts.set_selection(selection);
2158    }
2159}
2160impl DynamicUiHooks<SystemTray> for ConfigWindow {
2161    fn before_partial_build(
2162        &mut self,
2163        dynamic_ui: &Rc<SystemTray>,
2164        should_build: &mut bool,
2165    ) -> Option<(nwg::ControlHandle, std::any::TypeId)> {
2166        self.tray.set(dynamic_ui);
2167        if !self.open_soon.replace(false) {
2168            *should_build = false;
2169        }
2170        None
2171    }
2172    fn after_partial_build(&mut self, _dynamic_ui: &Rc<SystemTray>) {
2173        if let Err(e) = self.build_layout() {
2174            tracing::error!(
2175                error = e.to_string(),
2176                "Failed to build layout for ConfigWindow"
2177            );
2178        }
2179        if let Err(e) = self.build_tooltip() {
2180            tracing::error!(
2181                error = e.to_string(),
2182                "Failed to build tooltips for ConfigWindow"
2183            );
2184        }
2185
2186        self.sync_program_options_from_settings(None);
2187        self.set_as_foreground_window();
2188    }
2189    fn after_handles<'a>(
2190        &'a self,
2191        _dynamic_ui: &Rc<SystemTray>,
2192        handles: &mut Vec<&'a nwg::ControlHandle>,
2193    ) {
2194        *handles = vec![&self.window.handle];
2195    }
2196
2197    fn need_rebuild(&self, _dynamic_ui: &Rc<SystemTray>) -> bool {
2198        // Note: we should remain open even if open_soon is false.
2199        self.open_soon.get() && self.is_closed()
2200    }
2201    fn is_ordered_in_parent(&self) -> bool {
2202        false
2203    }
2204    fn before_rebuild(&mut self, _dynamic_ui: &Rc<SystemTray>) {
2205        let export_dialog = std::mem::take(&mut self.export_dialog);
2206        let import_dialog = std::mem::take(&mut self.import_dialog);
2207        *self = Default::default();
2208        self.export_dialog = export_dialog;
2209        self.import_dialog = import_dialog;
2210        // need_rebuild would only return true if open_soon was true, so
2211        // remember it:
2212        self.open_soon = Cell::new(true);
2213    }
2214}
2215impl TrayPlugin for ConfigWindow {
2216    fn on_settings_changed(
2217        &self,
2218        _tray_ui: &Rc<SystemTray>,
2219        _prev: &Arc<UiSettings>,
2220        new: &Arc<UiSettings>,
2221    ) {
2222        self.sync_program_options_from_settings(Some(new));
2223        let has_changed_filters =
2224            self.loaded_filters.borrow().as_deref().unwrap_or_default() != &*new.filters;
2225        if has_changed_filters {
2226            self.sync_filter_from_settings(Some(new));
2227        }
2228    }
2229}