1use 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
29fn 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
47fn warn_wayland_noop(op: &str) {
49 if is_wayland() {
50 log::debug!("{op}: no-op on Wayland (compositor-controlled)");
51 }
52}
53
54impl App {
59 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 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 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 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 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 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 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 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 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 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
670const MAX_WINDOW_DIM: f32 = 16384.0;
676
677pub 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
768fn 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}