Skip to main content

oxiui_core/
window.rs

1//! Multi-window support for OxiUI.
2//!
3//! Provides [`WindowId`] handles, per-window [`WidgetTree`] management via
4//! [`WindowManager`], a typed [`WindowConfig`] builder, [`WindowEvent`]
5//! lifecycle events, and a thread-safe [`WindowChannel`] for cross-window
6//! message passing.
7
8use std::collections::HashMap;
9use std::sync::{Arc, Mutex};
10
11use crate::{Rect, UiError, WidgetTree};
12
13// ── WindowId ─────────────────────────────────────────────────────────────────
14
15/// Opaque handle identifying a window.
16///
17/// Obtained from [`WindowManager::create_window`] and used to address
18/// per-window widget trees and route events.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct WindowId(pub u64);
21
22impl WindowId {
23    /// The implicit primary / root window (id = 1).
24    pub const PRIMARY: Self = WindowId(1);
25}
26
27impl std::fmt::Display for WindowId {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        write!(f, "Window({})", self.0)
30    }
31}
32
33// ── WindowConfig ─────────────────────────────────────────────────────────────
34
35/// Configuration for creating a new window.
36#[derive(Clone, Debug)]
37pub struct WindowConfig {
38    /// Window title string.
39    pub title: String,
40    /// Logical width in pixels.
41    pub width: f32,
42    /// Logical height in pixels.
43    pub height: f32,
44    /// Whether the window may be resized by the user.
45    pub resizable: bool,
46    /// Whether to show the OS window decorations (title bar, borders).
47    pub decorations: bool,
48    /// Whether the window background is transparent.
49    pub transparent: bool,
50    /// Whether the window floats above all other windows.
51    pub always_on_top: bool,
52}
53
54impl Default for WindowConfig {
55    fn default() -> Self {
56        WindowConfig {
57            title: String::new(),
58            width: 800.0,
59            height: 600.0,
60            resizable: true,
61            decorations: true,
62            transparent: false,
63            always_on_top: false,
64        }
65    }
66}
67
68impl WindowConfig {
69    /// Create a config with the given title and default dimensions.
70    pub fn new(title: impl Into<String>) -> Self {
71        WindowConfig {
72            title: title.into(),
73            ..Default::default()
74        }
75    }
76
77    /// Set the logical width.
78    pub fn width(mut self, w: f32) -> Self {
79        self.width = w;
80        self
81    }
82
83    /// Set the logical height.
84    pub fn height(mut self, h: f32) -> Self {
85        self.height = h;
86        self
87    }
88
89    /// Set whether the window is resizable.
90    pub fn resizable(mut self, r: bool) -> Self {
91        self.resizable = r;
92        self
93    }
94
95    /// Set whether window decorations are shown.
96    pub fn decorations(mut self, d: bool) -> Self {
97        self.decorations = d;
98        self
99    }
100
101    /// Set whether the window background is transparent.
102    pub fn transparent(mut self, t: bool) -> Self {
103        self.transparent = t;
104        self
105    }
106
107    /// Set whether the window is always on top.
108    pub fn always_on_top(mut self, a: bool) -> Self {
109        self.always_on_top = a;
110        self
111    }
112}
113
114// ── WindowEvent ──────────────────────────────────────────────────────────────
115
116/// Events related to window lifecycle and state.
117#[derive(Clone, Debug)]
118#[non_exhaustive]
119pub enum WindowEvent {
120    /// A new window was successfully created.
121    Created(WindowId),
122    /// A window was closed and its resources freed.
123    Closed(WindowId),
124    /// A window was resized to new logical dimensions.
125    Resized {
126        /// The window that was resized.
127        id: WindowId,
128        /// New logical width in pixels.
129        width: f32,
130        /// New logical height in pixels.
131        height: f32,
132    },
133    /// A window gained keyboard/pointer focus.
134    FocusGained(WindowId),
135    /// A window lost keyboard/pointer focus.
136    FocusLost(WindowId),
137    /// A cross-window message was dispatched from one window to another.
138    Message {
139        /// Originating window.
140        from: WindowId,
141        /// Target window.
142        to: WindowId,
143        /// Arbitrary UTF-8 payload.
144        payload: String,
145    },
146}
147
148// ── WindowChannel ─────────────────────────────────────────────────────────────
149
150/// Thread-safe cross-window communication channel.
151///
152/// Messages sent to a `WindowId` are queued until the window drains them via
153/// [`drain_messages`](WindowChannel::drain_messages).  The channel is cheaply
154/// cloneable via [`Clone`] (backed by an [`Arc`]).
155#[derive(Clone, Debug, Default)]
156pub struct WindowChannel {
157    queues: Arc<Mutex<HashMap<WindowId, Vec<String>>>>,
158}
159
160impl WindowChannel {
161    /// Create a new, empty channel.
162    pub fn new() -> Self {
163        WindowChannel::default()
164    }
165
166    /// Enqueue a message for the target window.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`UiError::Focus`] when the internal lock is poisoned
171    /// (extremely rare; only occurs after a panic inside `drain_messages`).
172    pub fn send(&self, to: WindowId, payload: impl Into<String>) -> Result<(), UiError> {
173        let mut guard = self
174            .queues
175            .lock()
176            .map_err(|_| UiError::Focus("window-channel lock poisoned".into()))?;
177        guard.entry(to).or_default().push(payload.into());
178        Ok(())
179    }
180
181    /// Drain all queued messages for the given window, returning them in
182    /// arrival order.  The internal queue for that window is cleared.
183    ///
184    /// # Errors
185    ///
186    /// Returns [`UiError::Focus`] when the lock is poisoned.
187    pub fn drain_messages(&self, id: WindowId) -> Result<Vec<String>, UiError> {
188        let mut guard = self
189            .queues
190            .lock()
191            .map_err(|_| UiError::Focus("window-channel lock poisoned".into()))?;
192        Ok(guard.remove(&id).unwrap_or_default())
193    }
194
195    /// Return the number of pending messages for a window without consuming them.
196    pub fn pending_count(&self, id: WindowId) -> usize {
197        self.queues
198            .lock()
199            .map(|g| g.get(&id).map_or(0, Vec::len))
200            .unwrap_or(0)
201    }
202}
203
204// ── WindowManager ─────────────────────────────────────────────────────────────
205
206/// Manages per-window [`WidgetTree`]s and window lifecycle.
207///
208/// The primary window ([`WindowId::PRIMARY`]) is always present and cannot
209/// be destroyed.  Secondary windows are created via
210/// [`create_window`](WindowManager::create_window) and removed via
211/// [`destroy_window`](WindowManager::destroy_window).
212pub struct WindowManager {
213    next_id: u64,
214    trees: HashMap<WindowId, WidgetTree>,
215    configs: HashMap<WindowId, WindowConfig>,
216    channel: WindowChannel,
217}
218
219impl WindowManager {
220    /// Create a new manager with the primary window having the given root rect.
221    pub fn new(primary_rect: Rect) -> Self {
222        let mut trees = HashMap::new();
223        let mut configs = HashMap::new();
224        trees.insert(WindowId::PRIMARY, WidgetTree::new(primary_rect));
225        configs.insert(WindowId::PRIMARY, WindowConfig::new("Main"));
226        WindowManager {
227            next_id: 2,
228            trees,
229            configs,
230            channel: WindowChannel::new(),
231        }
232    }
233
234    /// Create a secondary window with the given configuration, returning its id.
235    ///
236    /// The new window's widget tree is initialised with a root rect matching
237    /// the config's width/height dimensions.
238    pub fn create_window(&mut self, config: WindowConfig) -> WindowId {
239        let id = WindowId(self.next_id);
240        self.next_id += 1;
241        let rect = Rect::new(0.0, 0.0, config.width, config.height);
242        self.trees.insert(id, WidgetTree::new(rect));
243        self.configs.insert(id, config);
244        id
245    }
246
247    /// Destroy a secondary window, freeing its widget tree.
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if `id` is [`WindowId::PRIMARY`] or does not exist.
252    pub fn destroy_window(&mut self, id: WindowId) -> Result<(), UiError> {
253        if id == WindowId::PRIMARY {
254            return Err(UiError::Focus("cannot destroy the primary window".into()));
255        }
256        if self.trees.remove(&id).is_none() {
257            return Err(UiError::Focus(format!("window {id} not found")));
258        }
259        self.configs.remove(&id);
260        Ok(())
261    }
262
263    /// Borrow the [`WidgetTree`] for a window.
264    pub fn tree(&self, id: WindowId) -> Option<&WidgetTree> {
265        self.trees.get(&id)
266    }
267
268    /// Mutably borrow the [`WidgetTree`] for a window.
269    pub fn tree_mut(&mut self, id: WindowId) -> Option<&mut WidgetTree> {
270        self.trees.get_mut(&id)
271    }
272
273    /// Borrow the [`WindowConfig`] for a window.
274    pub fn config(&self, id: WindowId) -> Option<&WindowConfig> {
275        self.configs.get(&id)
276    }
277
278    /// Return a sorted list of all open window ids.
279    pub fn window_ids(&self) -> Vec<WindowId> {
280        let mut ids: Vec<WindowId> = self.trees.keys().copied().collect();
281        ids.sort();
282        ids
283    }
284
285    /// Return the number of currently open windows.
286    pub fn window_count(&self) -> usize {
287        self.trees.len()
288    }
289
290    /// Borrow the shared cross-window communication channel.
291    pub fn channel(&self) -> &WindowChannel {
292        &self.channel
293    }
294
295    /// Update a window's logical dimensions and reinitialise its widget tree.
296    ///
297    /// This is a blunt resize: all existing widget nodes are dropped and the
298    /// tree is recreated with only the root node.  Adapters that cache the
299    /// tree contents should rebuild after calling this method.
300    pub fn resize_window(&mut self, id: WindowId, width: f32, height: f32) {
301        if let Some(cfg) = self.configs.get_mut(&id) {
302            cfg.width = width;
303            cfg.height = height;
304        }
305        if let Some(tree) = self.trees.get_mut(&id) {
306            *tree = WidgetTree::new(Rect::new(0.0, 0.0, width, height));
307        }
308    }
309}
310
311impl Default for WindowManager {
312    fn default() -> Self {
313        Self::new(Rect::new(0.0, 0.0, 800.0, 600.0))
314    }
315}
316
317// ── Tests ────────────────────────────────────────────────────────────────────
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn primary_window_always_present() {
325        let mgr = WindowManager::default();
326        assert!(mgr.tree(WindowId::PRIMARY).is_some());
327        assert_eq!(mgr.window_count(), 1);
328    }
329
330    #[test]
331    fn create_window_returns_unique_ids() {
332        let mut mgr = WindowManager::default();
333        let id1 = mgr.create_window(WindowConfig::new("w1"));
334        let id2 = mgr.create_window(WindowConfig::new("w2"));
335        assert_ne!(id1, id2);
336        assert_ne!(id1, WindowId::PRIMARY);
337        assert_ne!(id2, WindowId::PRIMARY);
338    }
339
340    #[test]
341    fn create_and_destroy_window() {
342        let mut mgr = WindowManager::default();
343        let id = mgr.create_window(WindowConfig::new("secondary"));
344        assert_eq!(mgr.window_count(), 2);
345        mgr.destroy_window(id).expect("destroy ok");
346        assert_eq!(mgr.window_count(), 1);
347        assert!(mgr.tree(id).is_none());
348    }
349
350    #[test]
351    fn destroy_primary_window_is_err() {
352        let mut mgr = WindowManager::default();
353        assert!(mgr.destroy_window(WindowId::PRIMARY).is_err());
354    }
355
356    #[test]
357    fn destroy_nonexistent_window_is_err() {
358        let mut mgr = WindowManager::default();
359        assert!(mgr.destroy_window(WindowId(99)).is_err());
360    }
361
362    #[test]
363    fn window_ids_sorted() {
364        let mut mgr = WindowManager::default();
365        let id2 = mgr.create_window(WindowConfig::default());
366        let id3 = mgr.create_window(WindowConfig::default());
367        let ids = mgr.window_ids();
368        assert_eq!(ids[0], WindowId::PRIMARY);
369        assert!(ids.contains(&id2));
370        assert!(ids.contains(&id3));
371        // Verify sorted order.
372        for w in ids.windows(2) {
373            assert!(w[0] < w[1]);
374        }
375    }
376
377    #[test]
378    fn window_channel_send_and_drain() {
379        let ch = WindowChannel::new();
380        let wid = WindowId(10);
381        ch.send(wid, "hello").unwrap();
382        ch.send(wid, "world").unwrap();
383        let msgs = ch.drain_messages(wid).unwrap();
384        assert_eq!(msgs, vec!["hello", "world"]);
385        // Queue is empty after drain.
386        assert_eq!(ch.pending_count(wid), 0);
387    }
388
389    #[test]
390    fn window_channel_pending_count() {
391        let ch = WindowChannel::new();
392        let wid = WindowId(20);
393        assert_eq!(ch.pending_count(wid), 0);
394        ch.send(wid, "a").unwrap();
395        ch.send(wid, "b").unwrap();
396        assert_eq!(ch.pending_count(wid), 2);
397        ch.drain_messages(wid).unwrap();
398        assert_eq!(ch.pending_count(wid), 0);
399    }
400
401    #[test]
402    fn window_channel_clone_shares_state() {
403        let ch = WindowChannel::new();
404        let ch2 = ch.clone();
405        let wid = WindowId(30);
406        ch.send(wid, "msg").unwrap();
407        // Cloned handle sees the same queue.
408        assert_eq!(ch2.pending_count(wid), 1);
409        let msgs = ch2.drain_messages(wid).unwrap();
410        assert_eq!(msgs, vec!["msg"]);
411    }
412
413    #[test]
414    fn window_channel_separate_queues_per_window() {
415        let ch = WindowChannel::new();
416        let a = WindowId(40);
417        let b = WindowId(41);
418        ch.send(a, "for-a").unwrap();
419        ch.send(b, "for-b").unwrap();
420        assert_eq!(ch.pending_count(a), 1);
421        assert_eq!(ch.pending_count(b), 1);
422        let drained_a = ch.drain_messages(a).unwrap();
423        assert_eq!(drained_a, vec!["for-a"]);
424        assert_eq!(ch.pending_count(b), 1); // b unaffected
425    }
426
427    #[test]
428    fn window_config_builder() {
429        let cfg = WindowConfig::new("Test")
430            .width(1024.0)
431            .height(768.0)
432            .resizable(false)
433            .decorations(false)
434            .transparent(true)
435            .always_on_top(true);
436        assert_eq!(cfg.title, "Test");
437        assert_eq!(cfg.width, 1024.0);
438        assert_eq!(cfg.height, 768.0);
439        assert!(!cfg.resizable);
440        assert!(!cfg.decorations);
441        assert!(cfg.transparent);
442        assert!(cfg.always_on_top);
443    }
444
445    #[test]
446    fn resize_window_updates_config_and_tree() {
447        let mut mgr = WindowManager::default();
448        mgr.resize_window(WindowId::PRIMARY, 1920.0, 1080.0);
449        let cfg = mgr.config(WindowId::PRIMARY).unwrap();
450        assert_eq!(cfg.width, 1920.0);
451        assert_eq!(cfg.height, 1080.0);
452    }
453
454    #[test]
455    fn window_event_debug_is_non_empty() {
456        let e = WindowEvent::Created(WindowId::PRIMARY);
457        let s = format!("{e:?}");
458        assert!(!s.is_empty());
459    }
460
461    #[test]
462    fn window_id_display() {
463        assert_eq!(format!("{}", WindowId::PRIMARY), "Window(1)");
464        assert_eq!(format!("{}", WindowId(42)), "Window(42)");
465    }
466
467    #[test]
468    fn window_id_ordering() {
469        let mut ids = vec![WindowId(3), WindowId(1), WindowId(2)];
470        ids.sort();
471        assert_eq!(ids, vec![WindowId(1), WindowId(2), WindowId(3)]);
472    }
473}