Skip to main content

plushie_renderer_lib/
window_map.rs

1//! Bidirectional window ID mapping with associated per-window state.
2//!
3//! Wraps the window ID <-> iced window::Id relationship and any
4//! per-window state (decoration, theme cache) in a single type.
5//! Insertions and removals are atomic: it's impossible to update
6//! one side without the other.
7
8use iced::{Theme, window};
9use plushie_widget_sdk::runtime::ThemeChrome;
10use std::collections::HashMap;
11
12/// Per-window state beyond the ID mapping.
13struct WindowState {
14    /// Current decoration state. iced only exposes toggle_decorations(),
15    /// so we track the boolean to avoid toggling when already correct.
16    decorated: bool,
17    /// Resolved theme for this window, if set via the tree's theme prop.
18    /// None means "use app theme" unless theme_follows_system is set.
19    theme: Option<Theme>,
20    theme_follows_system: bool,
21    theme_chrome: ThemeChrome,
22    /// Per-window scale factor override. None means "use global default".
23    scale_factor: Option<f32>,
24}
25
26impl Default for WindowState {
27    fn default() -> Self {
28        Self {
29            decorated: true,
30            theme: None,
31            theme_follows_system: false,
32            theme_chrome: ThemeChrome::default(),
33            scale_factor: None,
34        }
35    }
36}
37
38/// Bidirectional window ID <-> iced window::Id mapping with per-window
39/// state. All mutations keep both maps in sync; callers cannot
40/// accidentally desync the forward and reverse maps.
41pub struct WindowMap {
42    /// Window ID -> (iced window ID, per-window state).
43    forward: HashMap<String, (window::Id, WindowState)>,
44    /// Iced window ID -> window ID.
45    reverse: HashMap<window::Id, String>,
46}
47
48impl Default for WindowMap {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54impl WindowMap {
55    pub fn new() -> Self {
56        Self {
57            forward: HashMap::new(),
58            reverse: HashMap::new(),
59        }
60    }
61
62    /// Insert a new window mapping. If the window_id already exists,
63    /// the old iced_id is removed from the reverse map to prevent
64    /// dangling entries.
65    pub fn insert(&mut self, window_id: String, iced_id: window::Id) {
66        if let Some((old_iced_id, _)) = self.forward.get(&window_id) {
67            self.reverse.remove(old_iced_id);
68        }
69        self.forward
70            .insert(window_id.clone(), (iced_id, WindowState::default()));
71        self.reverse.insert(iced_id, window_id);
72    }
73
74    pub fn remove_by_iced(&mut self, iced_id: &window::Id) -> Option<String> {
75        if let Some(window_id) = self.reverse.remove(iced_id) {
76            self.forward.remove(&window_id);
77            Some(window_id)
78        } else {
79            None
80        }
81    }
82
83    pub fn remove_by_window(&mut self, window_id: &str) -> Option<window::Id> {
84        if let Some((iced_id, _)) = self.forward.remove(window_id) {
85            self.reverse.remove(&iced_id);
86            Some(iced_id)
87        } else {
88            None
89        }
90    }
91
92    pub fn contains_window(&self, window_id: &str) -> bool {
93        self.forward.contains_key(window_id)
94    }
95
96    pub fn get_iced(&self, window_id: &str) -> Option<&window::Id> {
97        self.forward.get(window_id).map(|(id, _)| id)
98    }
99
100    /// Borrow the host-facing window ID for an iced window. Returns
101    /// `None` when the iced ID isn't tracked (e.g. late events after
102    /// the window has closed).
103    pub fn get_window_id(&self, iced_id: &window::Id) -> Option<&str> {
104        self.reverse.get(iced_id).map(String::as_str)
105    }
106
107    pub fn iced_ids(&self) -> impl Iterator<Item = &window::Id> {
108        self.reverse.keys()
109    }
110
111    pub fn window_ids(&self) -> impl Iterator<Item = &String> {
112        self.forward.keys()
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.forward.is_empty()
117    }
118
119    pub fn iter(&self) -> impl Iterator<Item = (&String, &window::Id)> {
120        self.forward.iter().map(|(jid, (iid, _))| (jid, iid))
121    }
122
123    pub fn clear(&mut self) {
124        self.forward.clear();
125        self.reverse.clear();
126    }
127
128    // -- Per-window decoration state --
129
130    pub fn is_decorated(&self, window_id: &str) -> bool {
131        self.forward.get(window_id).is_none_or(|(_, s)| s.decorated)
132    }
133
134    pub fn set_decorated(&mut self, window_id: &str, decorated: bool) {
135        if let Some((_, state)) = self.forward.get_mut(window_id) {
136            state.decorated = decorated;
137        }
138    }
139
140    // -- Per-window theme cache --
141
142    pub fn cached_theme(&self, window_id: &str) -> Option<&Theme> {
143        self.forward
144            .get(window_id)
145            .and_then(|(_, s)| s.theme.as_ref())
146    }
147
148    pub fn theme_follows_system(&self, window_id: &str) -> bool {
149        self.forward
150            .get(window_id)
151            .is_some_and(|(_, s)| s.theme_follows_system)
152    }
153
154    pub fn any_theme_follows_system(&self) -> bool {
155        self.forward
156            .values()
157            .any(|(_, state)| state.theme_follows_system)
158    }
159
160    pub fn cached_theme_chrome(&self, window_id: &str) -> Option<ThemeChrome> {
161        self.forward
162            .get(window_id)
163            .and_then(|(_, s)| s.theme.as_ref().map(|_| s.theme_chrome))
164    }
165
166    pub fn set_theme(&mut self, window_id: &str, theme: Theme, chrome: ThemeChrome) {
167        if let Some((_, state)) = self.forward.get_mut(window_id) {
168            state.theme = Some(theme);
169            state.theme_follows_system = false;
170            state.theme_chrome = chrome;
171        }
172    }
173
174    pub fn set_theme_follows_system(&mut self, window_id: &str) {
175        if let Some((_, state)) = self.forward.get_mut(window_id) {
176            state.theme = None;
177            state.theme_follows_system = true;
178            state.theme_chrome = ThemeChrome::default();
179        }
180    }
181
182    pub fn clear_theme_cache(&mut self) {
183        for (_, state) in self.forward.values_mut() {
184            state.theme = None;
185            state.theme_follows_system = false;
186            state.theme_chrome = ThemeChrome::default();
187        }
188    }
189
190    // -- Per-window scale factor --
191
192    pub fn scale_factor(&self, window_id: &str) -> Option<f32> {
193        self.forward
194            .get(window_id)
195            .and_then(|(_, s)| s.scale_factor)
196    }
197
198    pub fn set_scale_factor(&mut self, window_id: &str, scale_factor: Option<f32>) {
199        if let Some((_, state)) = self.forward.get_mut(window_id) {
200            state.scale_factor = scale_factor;
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn system_theme_is_distinct_from_missing_and_cached_theme() {
211        let mut map = WindowMap::new();
212        map.insert("main".to_string(), window::Id::unique());
213
214        assert!(!map.theme_follows_system("main"));
215        assert!(!map.any_theme_follows_system());
216        assert!(map.cached_theme("main").is_none());
217
218        map.set_theme("main", Theme::Light, ThemeChrome::default());
219        assert!(!map.theme_follows_system("main"));
220        assert!(!map.any_theme_follows_system());
221        assert!(matches!(map.cached_theme("main"), Some(Theme::Light)));
222
223        map.set_theme_follows_system("main");
224        assert!(map.theme_follows_system("main"));
225        assert!(map.any_theme_follows_system());
226        assert!(map.cached_theme("main").is_none());
227
228        map.clear_theme_cache();
229        assert!(!map.theme_follows_system("main"));
230        assert!(!map.any_theme_follows_system());
231        assert!(map.cached_theme("main").is_none());
232    }
233}