Skip to main content

plushie_renderer_lib/
window_ops.rs

1//! Window + system operations: typed dispatch for lifecycle (open,
2//! close, update), state changes (resize, move, maximize, mode,
3//! level, decorations, focus), queries (size, position, mode, scale
4//! factor, monitor, raw_id), and window sync.
5//!
6//! Dispatched from `CoreEffect::WindowOp(WindowOp)` and siblings via
7//! typed `match`. The renderer owns the `window_id -> iced::window::Id`
8//! map in `self.windows`; handlers look up the iced id per op.
9//!
10//! ## Platform notes
11//!
12//! Several operations are no-ops on Wayland because the compositor owns
13//! window positioning, focus, and icon management. When the renderer
14//! detects Wayland (via `WAYLAND_DISPLAY`), it logs a debug warning for
15//! these operations so SDK users can understand why their requests have
16//! no visible effect.
17
18use std::collections::HashSet;
19
20use iced::{Point, Size, Task, window};
21
22use plushie_core::ops::{
23    NotificationUrgency, SystemOp, SystemQuery, WindowLevel, WindowMode, WindowOp, WindowQuery,
24};
25use plushie_widget_sdk::runtime::Message;
26
27use crate::App;
28
29/// Returns true if the current display server is Wayland.
30///
31/// Detected via the `WAYLAND_DISPLAY` environment variable, which is
32/// set by Wayland compositors. Cached in a `OnceLock` so the env
33/// lookup happens at most once per process.
34/// On WASM, always returns false.
35fn is_wayland() -> bool {
36    #[cfg(not(target_arch = "wasm32"))]
37    {
38        static IS_WAYLAND: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
39        *IS_WAYLAND.get_or_init(|| std::env::var("WAYLAND_DISPLAY").is_ok())
40    }
41    #[cfg(target_arch = "wasm32")]
42    {
43        false
44    }
45}
46
47/// Log a debug warning when a window operation is a known no-op on Wayland.
48fn warn_wayland_noop(op: &str) {
49    if is_wayland() {
50        log::debug!("{op}: no-op on Wayland (compositor-controlled)");
51    }
52}
53
54// ---------------------------------------------------------------------------
55// Window operations (impl App)
56// ---------------------------------------------------------------------------
57
58impl App {
59    /// Dispatch a typed [`WindowOp`].
60    ///
61    /// Each variant maps to the appropriate iced window operation. The
62    /// window id is looked up in `self.windows`; unknown ids are logged
63    /// and produce `Task::none()`.
64    pub fn dispatch_window_op(&mut self, op: WindowOp) -> Task<Message> {
65        match op {
66            WindowOp::Open {
67                window_id,
68                settings,
69            } => {
70                if self.windows.contains_window(&window_id) {
71                    log::warn!("window_op open: {window_id} already open, skipping");
72                    return Task::none();
73                }
74                let win_settings = parse_window_settings(&settings);
75                let initial_decorations = win_settings.decorations;
76                let scale_factor = parse_scale_factor(&settings);
77                let (iced_id, open_task) = window::open(win_settings);
78
79                self.windows.insert(window_id.clone(), iced_id);
80                self.windows.set_decorated(&window_id, initial_decorations);
81                self.windows.set_scale_factor(&window_id, scale_factor);
82
83                open_task.map(move |id| Message::WindowOpened(id, window_id.clone()))
84            }
85            WindowOp::Update {
86                window_id,
87                settings,
88            } => {
89                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
90                    log::warn!("window_op update: unknown window_id: {window_id}");
91                    return Task::none();
92                };
93                let mut tasks: Vec<Task<Message>> = Vec::new();
94                let Some(obj) = settings.as_object() else {
95                    return Task::none();
96                };
97
98                // Title is read from the tree in title(), no task needed.
99                let _ = obj.get("title").and_then(|v| v.as_str());
100
101                if obj.contains_key("width") || obj.contains_key("height") {
102                    let w = obj.get("width").and_then(|v| v.as_f64()).unwrap_or(800.0) as f32;
103                    let h = obj.get("height").and_then(|v| v.as_f64()).unwrap_or(600.0) as f32;
104                    tasks.push(window::resize(iced_id, Size::new(w, h)));
105                }
106                if let Some(maximized) = obj.get("maximized").and_then(|v| v.as_bool()) {
107                    tasks.push(window::maximize(iced_id, maximized));
108                }
109                if let Some(resizable) = obj.get("resizable").and_then(|v| v.as_bool()) {
110                    tasks.push(window::set_resizable(iced_id, resizable));
111                }
112                // Note: visible and fullscreen both call set_mode. If both are
113                // present, the last one wins. Hosts should not set both.
114                if let Some(visible) = obj.get("visible").and_then(|v| v.as_bool()) {
115                    let mode = if visible {
116                        window::Mode::Windowed
117                    } else {
118                        window::Mode::Hidden
119                    };
120                    tasks.push(window::set_mode(iced_id, mode));
121                }
122                if let Some(fullscreen) = obj.get("fullscreen").and_then(|v| v.as_bool()) {
123                    let mode = if fullscreen {
124                        window::Mode::Fullscreen
125                    } else {
126                        window::Mode::Windowed
127                    };
128                    tasks.push(window::set_mode(iced_id, mode));
129                }
130                if obj.contains_key("min_size") {
131                    let sz = parse_optional_size(
132                        obj.get("min_size").unwrap_or(&serde_json::Value::Null),
133                    );
134                    tasks.push(window::set_min_size(iced_id, sz));
135                }
136                if obj.contains_key("max_size") {
137                    let sz = parse_optional_size(
138                        obj.get("max_size").unwrap_or(&serde_json::Value::Null),
139                    );
140                    tasks.push(window::set_max_size(iced_id, sz));
141                }
142                if obj.contains_key("level") {
143                    let level = parse_window_level_str(
144                        obj.get("level")
145                            .and_then(|v| v.as_str())
146                            .unwrap_or("normal"),
147                    );
148                    tasks.push(window::set_level(iced_id, level));
149                }
150                if let Some(desired) = obj.get("decorations").and_then(|v| v.as_bool()) {
151                    let current = self.windows.is_decorated(&window_id);
152                    if desired != current {
153                        self.windows.set_decorated(&window_id, desired);
154                        tasks.push(window::toggle_decorations(iced_id));
155                    }
156                }
157                if obj.contains_key("scale_factor") {
158                    let sf = parse_scale_factor(&serde_json::Value::Object(obj.clone()));
159                    self.windows.set_scale_factor(&window_id, sf);
160                }
161
162                Task::batch(tasks)
163            }
164            WindowOp::Close(window_id) => {
165                if let Some(iced_id) = self.windows.remove_by_window(&window_id) {
166                    window::close(iced_id)
167                } else {
168                    log::warn!("window_op close: unknown window_id: {window_id}");
169                    Task::none()
170                }
171            }
172            WindowOp::Resize {
173                window_id,
174                width,
175                height,
176            } => self.with_iced(&window_id, |id| {
177                window::resize(id, Size::new(width, height))
178            }),
179            WindowOp::Move { window_id, x, y } => {
180                warn_wayland_noop("move");
181                self.with_iced(&window_id, |id| window::move_to(id, Point::new(x, y)))
182            }
183            WindowOp::Maximize {
184                window_id,
185                maximized,
186            } => self.with_iced(&window_id, |id| window::maximize(id, maximized)),
187            WindowOp::Minimize {
188                window_id,
189                minimized,
190            } => self.with_iced(&window_id, |id| window::minimize(id, minimized)),
191            WindowOp::SetMode { window_id, mode } => {
192                let iced_mode = match mode {
193                    WindowMode::Fullscreen => window::Mode::Fullscreen,
194                    WindowMode::Windowed => window::Mode::Windowed,
195                };
196                self.with_iced(&window_id, |id| window::set_mode(id, iced_mode))
197            }
198            WindowOp::ToggleMaximize(window_id) => {
199                self.with_iced(&window_id, window::toggle_maximize)
200            }
201            WindowOp::ToggleDecorations(window_id) => {
202                let current = self.windows.is_decorated(&window_id);
203                self.windows.set_decorated(&window_id, !current);
204                self.with_iced(&window_id, window::toggle_decorations)
205            }
206            WindowOp::FocusWindow(window_id) => {
207                warn_wayland_noop("gain_focus");
208                self.with_iced(&window_id, window::gain_focus)
209            }
210            WindowOp::SetLevel { window_id, level } => {
211                let iced_level = match level {
212                    WindowLevel::Normal => window::Level::Normal,
213                    WindowLevel::AlwaysOnTop => window::Level::AlwaysOnTop,
214                    WindowLevel::AlwaysOnBottom => window::Level::AlwaysOnBottom,
215                };
216                self.with_iced(&window_id, |id| window::set_level(id, iced_level))
217            }
218            WindowOp::DragWindow(window_id) => self.with_iced(&window_id, window::drag),
219            WindowOp::DragResize {
220                window_id,
221                direction,
222            } => {
223                #[cfg(target_os = "macos")]
224                log::warn!("drag_resize is not supported on macOS");
225                let dir = parse_direction(&direction);
226                self.with_iced(&window_id, |id| window::drag_resize(id, dir))
227            }
228            WindowOp::RequestAttention { window_id, urgency } => {
229                let attention = urgency.map(|u| match u {
230                    NotificationUrgency::Critical => window::UserAttention::Critical,
231                    NotificationUrgency::Normal | NotificationUrgency::Low => {
232                        window::UserAttention::Informational
233                    }
234                });
235                self.with_iced(&window_id, |id| {
236                    window::request_user_attention(id, attention)
237                })
238            }
239            WindowOp::Screenshot { window_id, tag } => self.screenshot_task(&window_id, &tag),
240            WindowOp::SetResizable {
241                window_id,
242                resizable,
243            } => self.with_iced(&window_id, |id| window::set_resizable(id, resizable)),
244            WindowOp::SetMinSize {
245                window_id,
246                width,
247                height,
248            } => self.with_iced(&window_id, |id| {
249                window::set_min_size(id, Some(Size::new(width, height)))
250            }),
251            WindowOp::SetMaxSize {
252                window_id,
253                width,
254                height,
255            } => self.with_iced(&window_id, |id| {
256                window::set_max_size(id, Some(Size::new(width, height)))
257            }),
258            WindowOp::EnableMousePassthrough(window_id) => {
259                self.with_iced(&window_id, window::enable_mouse_passthrough)
260            }
261            WindowOp::DisableMousePassthrough(window_id) => {
262                self.with_iced(&window_id, window::disable_mouse_passthrough)
263            }
264            WindowOp::ShowSystemMenu(window_id) => {
265                #[cfg(not(target_os = "windows"))]
266                log::warn!("show_system_menu is only supported on Windows");
267                self.with_iced(&window_id, window::show_system_menu)
268            }
269            WindowOp::SetIcon {
270                window_id,
271                data,
272                width,
273                height,
274            } => {
275                warn_wayland_noop("set_icon");
276                self.dispatch_set_icon(&window_id, data, width, height)
277            }
278            WindowOp::SetResizeIncrements {
279                window_id,
280                width,
281                height,
282            } => self.with_iced(&window_id, |id| {
283                window::set_resize_increments(id, Some(Size::new(width, height)))
284            }),
285            _ => {
286                log::warn!("unhandled WindowOp variant");
287                Task::none()
288            }
289        }
290    }
291
292    /// Dispatch a typed [`WindowQuery`]. Each variant produces a
293    /// response event via the emitter sink when the underlying iced
294    /// task resolves.
295    pub fn dispatch_window_query(&mut self, q: WindowQuery) -> Task<Message> {
296        let sink = self.emitter.sink();
297        match q {
298            WindowQuery::GetSize { window_id, tag } => {
299                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
300                    return Task::none();
301                };
302                let wid = window_id.clone();
303                window::size(iced_id).map(move |size| {
304                    let data = serde_json::json!({
305                        "width": size.width,
306                        "height": size.height,
307                        "op": "get_size",
308                        "request_id": tag,
309                    });
310                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
311                    if let Err(e) = sink.lock().emit_effect_response(resp) {
312                        log::error!("write error: {e}");
313                    }
314                    Message::NoOp
315                })
316            }
317            WindowQuery::GetPosition { window_id, tag } => {
318                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
319                    return Task::none();
320                };
321                let wid = window_id.clone();
322                window::position(iced_id).map(move |pos| {
323                    let data = match pos {
324                        Some(p) => serde_json::json!({
325                            "x": p.x,
326                            "y": p.y,
327                            "op": "get_position",
328                            "request_id": tag,
329                        }),
330                        None => serde_json::json!({
331                            "op": "get_position",
332                            "request_id": tag,
333                        }),
334                    };
335                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
336                    if let Err(e) = sink.lock().emit_effect_response(resp) {
337                        log::error!("write error: {e}");
338                    }
339                    Message::NoOp
340                })
341            }
342            WindowQuery::GetMode { window_id, tag } => {
343                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
344                    return Task::none();
345                };
346                let wid = window_id.clone();
347                window::mode(iced_id).map(move |mode| {
348                    let mode_str = match mode {
349                        window::Mode::Windowed => "windowed",
350                        window::Mode::Fullscreen => "fullscreen",
351                        window::Mode::Hidden => "hidden",
352                    };
353                    let data = serde_json::json!({
354                        "mode": mode_str,
355                        "op": "get_mode",
356                        "request_id": tag,
357                    });
358                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
359                    if let Err(e) = sink.lock().emit_effect_response(resp) {
360                        log::error!("write error: {e}");
361                    }
362                    Message::NoOp
363                })
364            }
365            WindowQuery::GetScaleFactor { window_id, tag } => {
366                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
367                    return Task::none();
368                };
369                let wid = window_id.clone();
370                window::scale_factor(iced_id).map(move |factor| {
371                    let data = serde_json::json!({
372                        "scale_factor": factor,
373                        "op": "get_scale_factor",
374                        "request_id": tag,
375                    });
376                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
377                    if let Err(e) = sink.lock().emit_effect_response(resp) {
378                        log::error!("write error: {e}");
379                    }
380                    Message::NoOp
381                })
382            }
383            WindowQuery::IsMaximized { window_id, tag } => {
384                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
385                    return Task::none();
386                };
387                let wid = window_id.clone();
388                window::is_maximized(iced_id).map(move |val| {
389                    let data = serde_json::json!({
390                        "maximized": val,
391                        "op": "is_maximized",
392                        "request_id": tag,
393                    });
394                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
395                    if let Err(e) = sink.lock().emit_effect_response(resp) {
396                        log::error!("write error: {e}");
397                    }
398                    Message::NoOp
399                })
400            }
401            WindowQuery::IsMinimized { window_id, tag } => {
402                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
403                    return Task::none();
404                };
405                let wid = window_id.clone();
406                window::is_minimized(iced_id).map(move |val| {
407                    let data = serde_json::json!({
408                        "minimized": val,
409                        "op": "is_minimized",
410                        "request_id": tag,
411                    });
412                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
413                    if let Err(e) = sink.lock().emit_effect_response(resp) {
414                        log::error!("write error: {e}");
415                    }
416                    Message::NoOp
417                })
418            }
419            WindowQuery::MonitorSize { window_id, tag } => {
420                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
421                    return Task::none();
422                };
423                let wid = window_id.clone();
424                window::monitor_size(iced_id).map(move |size_opt| {
425                    let data = match size_opt {
426                        Some(size) => serde_json::json!({
427                            "width": size.width,
428                            "height": size.height,
429                            "op": "monitor_size",
430                            "request_id": tag,
431                        }),
432                        None => serde_json::json!({
433                            "op": "monitor_size",
434                            "request_id": tag,
435                        }),
436                    };
437                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
438                    if let Err(e) = sink.lock().emit_effect_response(resp) {
439                        log::error!("write error: {e}");
440                    }
441                    Message::NoOp
442                })
443            }
444            WindowQuery::RawId { window_id, tag } => {
445                let Some(&iced_id) = self.windows.get_iced(&window_id) else {
446                    return Task::none();
447                };
448                let wid = window_id.clone();
449                window::raw_id::<Message>(iced_id).map(move |raw| {
450                    let data = serde_json::json!({
451                        "raw_id": raw,
452                        "op": "raw_id",
453                        "platform": std::env::consts::OS,
454                        "request_id": tag,
455                    });
456                    let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
457                    if let Err(e) = sink.lock().emit_effect_response(resp) {
458                        log::error!("write error: {e}");
459                    }
460                    Message::NoOp
461                })
462            }
463            _ => {
464                log::warn!("unhandled WindowQuery variant");
465                Task::none()
466            }
467        }
468    }
469
470    /// Dispatch a typed [`SystemOp`].
471    pub fn dispatch_system_op(&mut self, op: SystemOp) -> Task<Message> {
472        match op {
473            SystemOp::AllowAutomaticTabbing(enabled) => window::allow_automatic_tabbing(enabled),
474        }
475    }
476
477    /// Dispatch a typed [`SystemQuery`]. Responses are emitted via the
478    /// sink when the underlying iced task resolves.
479    pub fn dispatch_system_query(&mut self, q: SystemQuery) -> Task<Message> {
480        let sink = self.emitter.sink();
481        match q {
482            SystemQuery::GetTheme { tag } => iced::system::theme().map(move |mode| {
483                let mode_str = match mode {
484                    iced::theme::Mode::Light => "light",
485                    iced::theme::Mode::Dark => "dark",
486                    iced::theme::Mode::None => "none",
487                };
488                let mut guard = sink.lock();
489                if let Err(e) =
490                    guard.emit_query_response("system_theme", &tag, &serde_json::json!(mode_str))
491                {
492                    log::error!("write error: {e}");
493                }
494                Message::NoOp
495            }),
496            #[cfg(not(target_arch = "wasm32"))]
497            SystemQuery::GetInfo { tag } => iced::system::information().map(move |info| {
498                let data = serde_json::json!({
499                    "system_name": info.system_name,
500                    "system_kernel": info.system_kernel,
501                    "system_version": info.system_version,
502                    "system_short_version": info.system_short_version,
503                    "cpu_brand": info.cpu_brand,
504                    "cpu_cores": info.cpu_cores,
505                    "memory_total": info.memory_total,
506                    "memory_used": info.memory_used,
507                    "graphics_backend": info.graphics_backend,
508                    "graphics_adapter": info.graphics_adapter,
509                });
510                let mut guard = sink.lock();
511                if let Err(e) = guard.emit_query_response("system_info", &tag, &data) {
512                    log::error!("write error: {e}");
513                }
514                Message::NoOp
515            }),
516            #[cfg(target_arch = "wasm32")]
517            SystemQuery::GetInfo { .. } => Task::none(),
518            _ => {
519                log::warn!("unhandled SystemQuery variant");
520                Task::none()
521            }
522        }
523    }
524
525    fn with_iced(
526        &self,
527        window_id: &str,
528        f: impl FnOnce(window::Id) -> Task<Message>,
529    ) -> Task<Message> {
530        match self.windows.get_iced(window_id) {
531            Some(&id) => f(id),
532            None => {
533                log::warn!("window_op: unknown window_id: {window_id}");
534                Task::none()
535            }
536        }
537    }
538
539    fn screenshot_task(&self, window_id: &str, tag: &str) -> Task<Message> {
540        let Some(&iced_id) = self.windows.get_iced(window_id) else {
541            return Task::none();
542        };
543        use base64::Engine as _;
544        let sink = self.emitter.sink();
545        let wid = window_id.to_string();
546        let tag = tag.to_string();
547        window::screenshot(iced_id).map(move |screenshot| {
548            let rgba_b64 = base64::engine::general_purpose::STANDARD.encode(&screenshot.rgba);
549            let data = serde_json::json!({
550                "width": screenshot.size.width,
551                "height": screenshot.size.height,
552                "bytes_len": screenshot.rgba.len(),
553                "rgba": rgba_b64,
554                "op": "screenshot",
555                "request_id": tag,
556            });
557            let resp = plushie_widget_sdk::protocol::EffectResponse::ok(wid.clone(), data);
558            if let Err(e) = sink.lock().emit_effect_response(resp) {
559                log::error!("write error: {e}");
560            }
561            Message::NoOp
562        })
563    }
564
565    fn dispatch_set_icon(
566        &self,
567        window_id: &str,
568        data: Vec<u8>,
569        width: u32,
570        height: u32,
571    ) -> Task<Message> {
572        const MAX_ICON_DIMENSION: u32 = 1024;
573        let Some(&iced_id) = self.windows.get_iced(window_id) else {
574            return Task::none();
575        };
576        if width == 0 || height == 0 {
577            log::error!("set_icon: zero dimension ({width}x{height})");
578            return Task::none();
579        }
580        if width > MAX_ICON_DIMENSION || height > MAX_ICON_DIMENSION {
581            log::error!(
582                "set_icon: dimensions {width}x{height} exceed maximum {MAX_ICON_DIMENSION}"
583            );
584            return Task::none();
585        }
586        if width != height {
587            log::warn!(
588                "set_icon: non-square icon ({width}x{height}); some platforms may render poorly"
589            );
590        }
591        let expected_len = match (width as usize)
592            .checked_mul(height as usize)
593            .and_then(|v| v.checked_mul(4))
594        {
595            Some(len) => len,
596            None => {
597                log::error!("set_icon: dimensions {width}x{height} would overflow");
598                return Task::none();
599            }
600        };
601        if data.len() != expected_len {
602            log::error!(
603                "set_icon: expected {expected_len} bytes ({width}x{height}x4), got {}",
604                data.len()
605            );
606            return Task::none();
607        }
608        match window::icon::from_rgba(data, width, height) {
609            Ok(icon) => window::set_icon(iced_id, icon),
610            Err(e) => {
611                log::error!("set_icon: icon creation failed: {e}");
612                Task::none()
613            }
614        }
615    }
616
617    /// Compare the set of window nodes in the tree against the currently open
618    /// windows and open/close as needed.
619    pub fn sync_windows(&mut self) -> Task<Message> {
620        let tree_windows: HashSet<String> = self.core.tree.window_ids().into_iter().collect();
621        let open_windows: HashSet<String> = self.windows.window_ids().cloned().collect();
622
623        let mut tasks = Vec::new();
624
625        // Open windows that exist in the tree but are not yet open.
626        for win_id in &tree_windows {
627            if !open_windows.contains(win_id) {
628                let scale_factor = self
629                    .core
630                    .tree
631                    .find_window(win_id)
632                    .and_then(|n| parse_scale_factor(&n.props.to_value()));
633                let settings = self.window_settings_for(win_id);
634                let initial_decorations = settings.decorations;
635                let (iced_id, open_task) = window::open(settings);
636                self.windows.insert(win_id.clone(), iced_id);
637                self.windows.set_decorated(win_id, initial_decorations);
638                self.windows.set_scale_factor(win_id, scale_factor);
639
640                let wid = win_id.clone();
641                tasks.push(open_task.map(move |id| Message::WindowOpened(id, wid.clone())));
642            }
643        }
644
645        // Close windows that are open but no longer in the tree.
646        for win_id in &open_windows {
647            if !tree_windows.contains(win_id)
648                && let Some(iced_id) = self.windows.remove_by_window(win_id)
649            {
650                tasks.push(window::close(iced_id));
651            }
652        }
653
654        Task::batch(tasks)
655    }
656
657    /// Build window::Settings from a window node's props.
658    pub fn window_settings_for(&self, window_id: &str) -> window::Settings {
659        if let Some(node) = self.core.tree.find_window(window_id) {
660            parse_window_settings(&node.props.to_value())
661        } else {
662            window::Settings {
663                size: Size::new(800.0, 600.0),
664                ..window::Settings::default()
665            }
666        }
667    }
668}
669
670// ---------------------------------------------------------------------------
671// Settings / enum parsing helpers
672// ---------------------------------------------------------------------------
673
674/// Maximum window dimension in logical pixels.
675const MAX_WINDOW_DIM: f32 = 16384.0;
676
677/// Parse a full `window::Settings` from a JSON value (node props or op settings).
678pub fn parse_window_settings(v: &serde_json::Value) -> window::Settings {
679    let mut width = v.get("width").and_then(|v| v.as_f64()).unwrap_or(800.0) as f32;
680    let mut height = v.get("height").and_then(|v| v.as_f64()).unwrap_or(600.0) as f32;
681    if !(1.0..=MAX_WINDOW_DIM).contains(&width) {
682        log::warn!("window width {width} out of range, clamping to 1.0..={MAX_WINDOW_DIM}");
683        width = width.clamp(1.0, MAX_WINDOW_DIM);
684    }
685    if !(1.0..=MAX_WINDOW_DIM).contains(&height) {
686        log::warn!("window height {height} out of range, clamping to 1.0..={MAX_WINDOW_DIM}");
687        height = height.clamp(1.0, MAX_WINDOW_DIM);
688    }
689
690    let maximized = v
691        .get("maximized")
692        .and_then(|v| v.as_bool())
693        .unwrap_or(false);
694    let fullscreen = v
695        .get("fullscreen")
696        .and_then(|v| v.as_bool())
697        .unwrap_or(false);
698    let visible = v.get("visible").and_then(|v| v.as_bool()).unwrap_or(true);
699    let resizable = v.get("resizable").and_then(|v| v.as_bool()).unwrap_or(true);
700    let closeable = v.get("closeable").and_then(|v| v.as_bool()).unwrap_or(true);
701    let minimizable = v
702        .get("minimizable")
703        .and_then(|v| v.as_bool())
704        .unwrap_or(true);
705    let decorations = v
706        .get("decorations")
707        .and_then(|v| v.as_bool())
708        .unwrap_or(true);
709    let transparent = v
710        .get("transparent")
711        .and_then(|v| v.as_bool())
712        .unwrap_or(false);
713    let blur = v.get("blur").and_then(|v| v.as_bool()).unwrap_or(false);
714    let exit_on_close_request = v
715        .get("exit_on_close_request")
716        .and_then(|v| v.as_bool())
717        .unwrap_or(true);
718
719    let position = match v.get("position") {
720        Some(serde_json::Value::String(s)) if s == "centered" => window::Position::Centered,
721        Some(obj) if obj.is_object() => {
722            const MAX_POS: f32 = 32768.0;
723            let mut x = obj.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
724            let mut y = obj.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
725            if !(-MAX_POS..=MAX_POS).contains(&x) {
726                log::warn!(
727                    "window position x={x} out of range, clamping to -{MAX_POS}..={MAX_POS}"
728                );
729                x = x.clamp(-MAX_POS, MAX_POS);
730            }
731            if !(-MAX_POS..=MAX_POS).contains(&y) {
732                log::warn!(
733                    "window position y={y} out of range, clamping to -{MAX_POS}..={MAX_POS}"
734                );
735                y = y.clamp(-MAX_POS, MAX_POS);
736            }
737            warn_wayland_noop("position");
738            window::Position::Specific(Point::new(x, y))
739        }
740        _ => window::Position::default(),
741    };
742
743    let min_size = parse_optional_size(v.get("min_size").unwrap_or(&serde_json::Value::Null));
744    let max_size = parse_optional_size(v.get("max_size").unwrap_or(&serde_json::Value::Null));
745
746    let level = parse_window_level_str(v.get("level").and_then(|v| v.as_str()).unwrap_or("normal"));
747
748    window::Settings {
749        size: Size::new(width, height),
750        maximized,
751        fullscreen,
752        position,
753        min_size,
754        max_size,
755        visible,
756        resizable,
757        closeable,
758        minimizable,
759        decorations,
760        transparent,
761        blur,
762        level,
763        exit_on_close_request,
764        ..window::Settings::default()
765    }
766}
767
768/// Extract an optional per-window scale_factor from a JSON value.
769/// Returns `None` when absent (meaning "use global default"), or
770/// `Some(validated)` when present.
771fn parse_scale_factor(v: &serde_json::Value) -> Option<f32> {
772    v.get("scale_factor")
773        .and_then(|v| v.as_f64())
774        .map(|v| crate::app::validate_scale_factor(v as f32))
775}
776
777fn parse_optional_size(v: &serde_json::Value) -> Option<Size> {
778    let w = v.get("width").and_then(|v| v.as_f64())? as f32;
779    let h = v.get("height").and_then(|v| v.as_f64())? as f32;
780    Some(Size::new(w, h))
781}
782
783fn parse_window_level_str(s: &str) -> window::Level {
784    match s {
785        "always_on_top" => window::Level::AlwaysOnTop,
786        "always_on_bottom" => window::Level::AlwaysOnBottom,
787        _ => window::Level::Normal,
788    }
789}
790
791fn parse_direction(s: &str) -> window::Direction {
792    match s {
793        "north" => window::Direction::North,
794        "south" => window::Direction::South,
795        "east" => window::Direction::East,
796        "west" => window::Direction::West,
797        "north_east" => window::Direction::NorthEast,
798        "north_west" => window::Direction::NorthWest,
799        "south_east" => window::Direction::SouthEast,
800        "south_west" => window::Direction::SouthWest,
801        _ => window::Direction::SouthEast,
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808    use iced::{Size, window};
809    use serde_json::json;
810
811    #[test]
812    fn parse_window_settings_defaults() {
813        let settings = parse_window_settings(&json!({}));
814        assert_eq!(settings.size, Size::new(800.0, 600.0));
815        assert!(settings.visible);
816        assert!(settings.resizable);
817        assert!(settings.decorations);
818        assert!(!settings.maximized);
819        assert!(!settings.fullscreen);
820        assert!(!settings.transparent);
821    }
822
823    #[test]
824    fn parse_window_settings_custom_size() {
825        let settings = parse_window_settings(&json!({"width": 1024, "height": 768}));
826        assert_eq!(settings.size, Size::new(1024.0, 768.0));
827    }
828
829    #[test]
830    fn parse_window_settings_centered_position() {
831        let settings = parse_window_settings(&json!({"position": "centered"}));
832        assert!(matches!(settings.position, window::Position::Centered));
833    }
834
835    #[test]
836    fn parse_window_settings_specific_position() {
837        let settings = parse_window_settings(&json!({"position": {"x": 100, "y": 200}}));
838        match settings.position {
839            window::Position::Specific(p) => {
840                assert_eq!(p.x, 100.0);
841                assert_eq!(p.y, 200.0);
842            }
843            _ => panic!("expected Specific position"),
844        }
845    }
846
847    #[test]
848    fn parse_window_settings_boolean_flags() {
849        let settings = parse_window_settings(&json!({
850            "maximized": true,
851            "transparent": true,
852            "decorations": false,
853            "resizable": false,
854        }));
855        assert!(settings.maximized);
856        assert!(settings.transparent);
857        assert!(!settings.decorations);
858        assert!(!settings.resizable);
859    }
860
861    #[test]
862    fn parse_optional_size_from_object() {
863        let sz = parse_optional_size(&json!({"width": 100, "height": 200}));
864        assert_eq!(sz, Some(Size::new(100.0, 200.0)));
865    }
866
867    #[test]
868    fn parse_optional_size_null() {
869        let sz = parse_optional_size(&json!(null));
870        assert_eq!(sz, None);
871    }
872
873    #[test]
874    fn parse_window_level_variants() {
875        assert!(matches!(
876            parse_window_level_str("always_on_top"),
877            window::Level::AlwaysOnTop
878        ));
879        assert!(matches!(
880            parse_window_level_str("always_on_bottom"),
881            window::Level::AlwaysOnBottom
882        ));
883        assert!(matches!(
884            parse_window_level_str("normal"),
885            window::Level::Normal
886        ));
887        assert!(matches!(
888            parse_window_level_str("unknown"),
889            window::Level::Normal
890        ));
891    }
892
893    #[test]
894    fn parse_direction_variants() {
895        assert!(matches!(parse_direction("north"), window::Direction::North));
896        assert!(matches!(
897            parse_direction("south_west"),
898            window::Direction::SouthWest
899        ));
900        assert!(matches!(
901            parse_direction("invalid"),
902            window::Direction::SouthEast
903        ));
904    }
905}