Skip to main content

plushie_core/
ops.rs

1//! Renderer operations and supporting types.
2//!
3//! [`RendererOp`] represents every operation the renderer can execute.
4//! These are the typed commands that flow from the SDK to the renderer
5//! with zero serialization overhead in direct mode.
6
7use std::fmt;
8use std::time::Duration;
9
10use serde_json::Map;
11use serde_json::Value;
12
13// ---------------------------------------------------------------------------
14// Typed enums for string-based parameters
15// ---------------------------------------------------------------------------
16
17/// Window display mode.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum WindowMode {
21    /// Windowed.
22    Windowed,
23    /// Fullscreen.
24    Fullscreen,
25}
26
27impl fmt::Display for WindowMode {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            Self::Windowed => f.write_str("windowed"),
31            Self::Fullscreen => f.write_str("fullscreen"),
32        }
33    }
34}
35
36/// Window stacking level.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum WindowLevel {
40    /// Normal.
41    Normal,
42    /// Always On Top.
43    AlwaysOnTop,
44    /// Always On Bottom.
45    AlwaysOnBottom,
46}
47
48impl fmt::Display for WindowLevel {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::Normal => f.write_str("normal"),
52            Self::AlwaysOnTop => f.write_str("always_on_top"),
53            Self::AlwaysOnBottom => f.write_str("always_on_bottom"),
54        }
55    }
56}
57
58/// Notification urgency level.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum NotificationUrgency {
62    /// Low.
63    Low,
64    /// Normal.
65    Normal,
66    /// Critical.
67    Critical,
68}
69
70impl fmt::Display for NotificationUrgency {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self {
73            Self::Low => f.write_str("low"),
74            Self::Normal => f.write_str("normal"),
75            Self::Critical => f.write_str("critical"),
76        }
77    }
78}
79
80// ---------------------------------------------------------------------------
81// RendererOp
82// ---------------------------------------------------------------------------
83
84/// An operation the renderer can execute.
85///
86/// In direct mode, these are passed in-process with zero serialization.
87/// In wire mode, they are serialized at the process boundary.
88#[derive(Debug)]
89#[non_exhaustive]
90pub enum RendererOp {
91    // -- Widget-targeted command --
92    /// Send a command to a widget by ID.
93    ///
94    /// Subsumes focus, scroll, text cursor, pane grid, and native
95    /// widget operations. The `family` string identifies the
96    /// operation; the `value` carries typed payload data.
97    Command {
98        /// Target widget ID.
99        id: String,
100        /// Event/command family identifier.
101        family: String,
102        /// Typed payload value.
103        value: Value,
104    },
105    /// Send multiple widget commands in a batch.
106    Commands(Vec<WidgetCommand>),
107
108    // -- Focus (global, no target widget) --
109    /// Move keyboard focus to the next focusable widget.
110    FocusNext,
111    /// Move keyboard focus to the previous focusable widget.
112    FocusPrevious,
113    /// Move keyboard focus to the next focusable widget within the
114    /// given scope. The scope is a widget ID; focus wraps within the
115    /// subtree rooted at that widget rather than walking the full
116    /// tree. Use for modal focus traps, menus, and other scoped
117    /// keyboard-navigation containers.
118    FocusNextWithin {
119        /// Scope widget ID that bounds the operation.
120        scope: String,
121    },
122    /// Move keyboard focus to the previous focusable widget within
123    /// the given scope.
124    FocusPreviousWithin {
125        /// Scope widget ID that bounds the operation.
126        scope: String,
127    },
128
129    // -- Window operations --
130    /// Perform a window operation (close, resize, move, etc.).
131    Window(WindowOp),
132    /// Query window state.
133    WindowQuery(WindowQuery),
134
135    // -- System --
136    /// Perform a system-level operation.
137    SystemOp(SystemOp),
138    /// Query system state.
139    SystemQuery(SystemQuery),
140
141    // -- Platform effects --
142    /// Request a platform effect (file dialog, clipboard, notification).
143    Effect {
144        /// Correlation tag used for matching responses.
145        tag: String,
146        /// Effect request payload.
147        request: EffectRequest,
148        /// Optional per-effect timeout override. When `None`, the runner
149        /// uses `effect_tracker::default_timeout` based on the effect kind.
150        timeout: Option<Duration>,
151    },
152
153    // -- Images --
154    /// Perform an image operation (create, update, delete).
155    Image(ImageOp),
156
157    // -- Accessibility --
158    /// Announce text to screen readers.
159    ///
160    /// `politeness` controls whether the announcement interrupts
161    /// ongoing speech (assertive) or queues after the current
162    /// utterance (polite). App code typically wants polite for
163    /// status messages and toast feedback; assertive is reserved
164    /// for urgent context that must reach the user immediately.
165    Announce {
166        /// Text payload.
167        text: String,
168        /// Screen-reader politeness (polite vs assertive).
169        politeness: crate::types::Live,
170    },
171    /// Load a font from raw byte data.
172    ///
173    /// `family` is the name the app will use when referring to this font
174    /// (via `default_font.family` in Settings or in widget font props).
175    /// The renderer records the family in the loaded-font registry so
176    /// `resolve_font_with_fallback` can match the name without parsing
177    /// font metadata.
178    LoadFont {
179        /// The family name the app will use to reference this font.
180        family: String,
181        /// Font file bytes (TrueType, OpenType, or TrueType Collection).
182        bytes: Vec<u8>,
183    },
184
185    // -- Subscriptions --
186    /// Subscribe to a renderer event source.
187    Subscribe {
188        /// Event kind string used on the wire.
189        kind: String,
190        /// Correlation tag used for matching responses.
191        tag: String,
192        /// Optional max delivery rate (events per second).
193        max_rate: Option<u32>,
194        /// Target window ID.
195        window_id: Option<String>,
196    },
197    /// Unsubscribe from a renderer event source.
198    Unsubscribe {
199        /// Event kind string used on the wire.
200        kind: String,
201        /// Correlation tag used for matching responses.
202        tag: String,
203    },
204
205    // -- Testing / debugging --
206    /// Request a hash of the current widget tree.
207    TreeHash {
208        /// Correlation tag used for matching responses.
209        tag: String,
210    },
211    /// Query which widget currently has keyboard focus.
212    FindFocused {
213        /// Correlation tag used for matching responses.
214        tag: String,
215    },
216    /// Advance renderer-side animation to the given timestamp in
217    /// headless/mock wire testing.
218    ///
219    /// Windowed daemon mode is driven by iced frame ticks instead and
220    /// ignores this operation.
221    AdvanceFrame {
222        /// Timestamp in milliseconds.
223        timestamp: u64,
224    },
225}
226
227// ---------------------------------------------------------------------------
228// Window operations
229// ---------------------------------------------------------------------------
230
231/// A window management operation.
232///
233/// Covers the full lifecycle (open, update props, close) plus every
234/// in-flight state change the renderer understands. Variants carry
235/// the typed data they need; the renderer dispatches on this enum
236/// rather than matching on string op names.
237#[derive(Debug)]
238#[non_exhaustive]
239pub enum WindowOp {
240    /// Open a new window with the given initial settings.
241    ///
242    /// `settings` is a JSON object with the subset of window
243    /// settings keys the host wants to specify (`title`, `size`,
244    /// `position`, `resizable`, `decorations`, etc.); any unspecified
245    /// field falls back to iced's defaults. Runtime-only fields
246    /// like `icon_data` are nested under their usual keys.
247    Open {
248        /// Target window ID.
249        window_id: String,
250        /// Initial window settings as a JSON object.
251        settings: Value,
252    },
253    /// Apply in-place changes to an already-open window.
254    ///
255    /// Only keys present in `settings` are applied; the renderer
256    /// leaves everything else untouched. Used when a surviving
257    /// window's node props change between renders.
258    Update {
259        /// Target window ID.
260        window_id: String,
261        /// Subset of window settings to apply.
262        settings: Value,
263    },
264    /// Close a window.
265    Close(String),
266    /// Resize a window to the given logical dimensions.
267    Resize {
268        /// Target window ID.
269        window_id: String,
270        /// Width in pixels.
271        width: f32,
272        /// Height in pixels.
273        height: f32,
274    },
275    /// Move a window to the given logical position.
276    Move {
277        /// Target window ID.
278        window_id: String,
279        /// X coordinate.
280        x: f32,
281        /// Y coordinate.
282        y: f32,
283    },
284    /// Set or unset the maximized state.
285    Maximize {
286        /// Target window ID.
287        window_id: String,
288        /// Whether the window is maximized.
289        maximized: bool,
290    },
291    /// Set or unset the minimized state.
292    Minimize {
293        /// Target window ID.
294        window_id: String,
295        /// Whether the window is minimized.
296        minimized: bool,
297    },
298    /// Set the window display mode.
299    SetMode {
300        /// Target window ID.
301        window_id: String,
302        /// Mode selector.
303        mode: WindowMode,
304    },
305    /// Toggle between maximized and restored states.
306    ToggleMaximize(String),
307    /// Toggle window decorations (title bar, borders).
308    ToggleDecorations(String),
309    /// Bring a window to the front and give it focus.
310    FocusWindow(String),
311    /// Set the window stacking level.
312    SetLevel {
313        /// Target window ID.
314        window_id: String,
315        /// Level selector.
316        level: WindowLevel,
317    },
318    /// Begin an interactive window drag.
319    DragWindow(String),
320    /// Begin an interactive window resize from the given edge/direction.
321    DragResize {
322        /// Target window ID.
323        window_id: String,
324        /// Direction of the operation.
325        direction: String,
326    },
327    /// Request user attention (taskbar flash or similar).
328    RequestAttention {
329        /// Target window ID.
330        window_id: String,
331        /// Notification urgency level.
332        urgency: Option<NotificationUrgency>,
333    },
334    /// Take a screenshot of a window.
335    Screenshot {
336        /// Target window ID.
337        window_id: String,
338        /// Correlation tag used for matching responses.
339        tag: String,
340    },
341    /// Set whether the window is user-resizable.
342    SetResizable {
343        /// Target window ID.
344        window_id: String,
345        /// Whether the window is resizable.
346        resizable: bool,
347    },
348    /// Set the minimum window size.
349    SetMinSize {
350        /// Target window ID.
351        window_id: String,
352        /// Width in pixels.
353        width: f32,
354        /// Height in pixels.
355        height: f32,
356    },
357    /// Set the maximum window size.
358    SetMaxSize {
359        /// Target window ID.
360        window_id: String,
361        /// Width in pixels.
362        width: f32,
363        /// Height in pixels.
364        height: f32,
365    },
366    /// Allow mouse events to pass through the window.
367    EnableMousePassthrough(String),
368    /// Stop mouse events from passing through the window.
369    DisableMousePassthrough(String),
370    /// Show the native system menu (right-click title bar menu).
371    ShowSystemMenu(String),
372    /// Set the window icon from raw RGBA pixel data.
373    SetIcon {
374        /// Target window ID.
375        window_id: String,
376        /// Raw bytes (pixels, font, etc.).
377        data: Vec<u8>,
378        /// Width in pixels.
379        width: u32,
380        /// Height in pixels.
381        height: u32,
382    },
383    /// Set window resize increment constraints.
384    SetResizeIncrements {
385        /// Target window ID.
386        window_id: String,
387        /// Width in pixels.
388        width: f32,
389        /// Height in pixels.
390        height: f32,
391    },
392}
393
394/// A query for window state.
395#[derive(Debug)]
396#[non_exhaustive]
397pub enum WindowQuery {
398    /// Get Size.
399    GetSize {
400        /// Target window ID.
401        window_id: String,
402        /// Correlation tag used for matching responses.
403        tag: String,
404    },
405    /// Get Position.
406    GetPosition {
407        /// Target window ID.
408        window_id: String,
409        /// Correlation tag used for matching responses.
410        tag: String,
411    },
412    /// Is Maximized.
413    IsMaximized {
414        /// Target window ID.
415        window_id: String,
416        /// Correlation tag used for matching responses.
417        tag: String,
418    },
419    /// Is Minimized.
420    IsMinimized {
421        /// Target window ID.
422        window_id: String,
423        /// Correlation tag used for matching responses.
424        tag: String,
425    },
426    /// Get Mode.
427    GetMode {
428        /// Target window ID.
429        window_id: String,
430        /// Correlation tag used for matching responses.
431        tag: String,
432    },
433    /// Get Scale Factor.
434    GetScaleFactor {
435        /// Target window ID.
436        window_id: String,
437        /// Correlation tag used for matching responses.
438        tag: String,
439    },
440    /// Monitor Size.
441    MonitorSize {
442        /// Target window ID.
443        window_id: String,
444        /// Correlation tag used for matching responses.
445        tag: String,
446    },
447    /// Raw Id.
448    RawId {
449        /// Target window ID.
450        window_id: String,
451        /// Correlation tag used for matching responses.
452        tag: String,
453    },
454}
455
456impl WindowOp {
457    /// Build a typed [`WindowOp`] from the wire-protocol `{op, window_id,
458    /// payload}` triple. Returns `None` for unrecognised op strings so the
459    /// caller can log a diagnostic and continue.
460    pub fn from_wire(op: &str, window_id: &str, payload: &Value) -> Option<Self> {
461        let wid = || window_id.to_string();
462        let f = |key: &str, default: f32| -> f32 {
463            payload
464                .get(key)
465                .and_then(|v| v.as_f64())
466                .map(|v| v as f32)
467                .unwrap_or(default)
468        };
469        let b = |key: &str, default: bool| -> bool {
470            payload
471                .get(key)
472                .and_then(|v| v.as_bool())
473                .unwrap_or(default)
474        };
475        let s = |key: &str| -> String {
476            payload
477                .get(key)
478                .and_then(|v| v.as_str())
479                .unwrap_or_default()
480                .to_string()
481        };
482        match op {
483            "open" => Some(Self::Open {
484                window_id: wid(),
485                settings: payload.clone(),
486            }),
487            "update" => Some(Self::Update {
488                window_id: wid(),
489                settings: payload.clone(),
490            }),
491            "close" => Some(Self::Close(wid())),
492            "resize" => Some(Self::Resize {
493                window_id: wid(),
494                width: f("width", 800.0),
495                height: f("height", 600.0),
496            }),
497            "move" => Some(Self::Move {
498                window_id: wid(),
499                x: f("x", 0.0),
500                y: f("y", 0.0),
501            }),
502            "maximize" => Some(Self::Maximize {
503                window_id: wid(),
504                maximized: b("maximized", true),
505            }),
506            "minimize" => Some(Self::Minimize {
507                window_id: wid(),
508                minimized: b("minimized", true),
509            }),
510            "set_mode" => {
511                let mode = payload
512                    .get("mode")
513                    .and_then(|v| v.as_str())
514                    .map(|s| match s {
515                        "fullscreen" => WindowMode::Fullscreen,
516                        _ => WindowMode::Windowed,
517                    })
518                    .unwrap_or(WindowMode::Windowed);
519                Some(Self::SetMode {
520                    window_id: wid(),
521                    mode,
522                })
523            }
524            "toggle_maximize" => Some(Self::ToggleMaximize(wid())),
525            "toggle_decorations" => Some(Self::ToggleDecorations(wid())),
526            "gain_focus" => Some(Self::FocusWindow(wid())),
527            "set_level" => {
528                let level = payload
529                    .get("level")
530                    .and_then(|v| v.as_str())
531                    .map(|s| match s {
532                        "always_on_top" => WindowLevel::AlwaysOnTop,
533                        "always_on_bottom" => WindowLevel::AlwaysOnBottom,
534                        _ => WindowLevel::Normal,
535                    })
536                    .unwrap_or(WindowLevel::Normal);
537                Some(Self::SetLevel {
538                    window_id: wid(),
539                    level,
540                })
541            }
542            "drag" => Some(Self::DragWindow(wid())),
543            "drag_resize" => Some(Self::DragResize {
544                window_id: wid(),
545                direction: s("direction"),
546            }),
547            "request_attention" => {
548                let urgency =
549                    payload
550                        .get("urgency")
551                        .and_then(|v| v.as_str())
552                        .and_then(|s| match s {
553                            "low" => Some(NotificationUrgency::Low),
554                            "normal" => Some(NotificationUrgency::Normal),
555                            "critical" => Some(NotificationUrgency::Critical),
556                            _ => None,
557                        });
558                Some(Self::RequestAttention {
559                    window_id: wid(),
560                    urgency,
561                })
562            }
563            "screenshot" => Some(Self::Screenshot {
564                window_id: wid(),
565                tag: s("tag"),
566            }),
567            "set_resizable" => Some(Self::SetResizable {
568                window_id: wid(),
569                resizable: b("resizable", true),
570            }),
571            "set_min_size" => Some(Self::SetMinSize {
572                window_id: wid(),
573                width: f("width", 0.0),
574                height: f("height", 0.0),
575            }),
576            "set_max_size" => Some(Self::SetMaxSize {
577                window_id: wid(),
578                width: f("width", 0.0),
579                height: f("height", 0.0),
580            }),
581            "mouse_passthrough" => {
582                let enabled = b("enabled", true);
583                if enabled {
584                    Some(Self::EnableMousePassthrough(wid()))
585                } else {
586                    Some(Self::DisableMousePassthrough(wid()))
587                }
588            }
589            "show_system_menu" => Some(Self::ShowSystemMenu(wid())),
590            "set_icon" => {
591                use base64::Engine as _;
592                let b64 = payload.get("data").and_then(|v| v.as_str())?;
593                let data = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
594                let width = payload.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
595                let height = payload.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
596                Some(Self::SetIcon {
597                    window_id: wid(),
598                    data,
599                    width,
600                    height,
601                })
602            }
603            "set_resize_increments" => Some(Self::SetResizeIncrements {
604                window_id: wid(),
605                width: f("width", 0.0),
606                height: f("height", 0.0),
607            }),
608            _ => None,
609        }
610    }
611
612    /// Emit the wire-protocol `(op, window_id, payload)` triple for this
613    /// typed WindowOp. Used by [`crate::ops::WindowOp`] consumers on the
614    /// SDK side that speak the JSON wire format to the renderer.
615    pub fn to_wire(&self) -> (&'static str, String, Value) {
616        use serde_json::json;
617        match self {
618            Self::Open {
619                window_id,
620                settings,
621            } => ("open", window_id.clone(), settings.clone()),
622            Self::Update {
623                window_id,
624                settings,
625            } => ("update", window_id.clone(), settings.clone()),
626            Self::Close(id) => ("close", id.clone(), Value::Null),
627            Self::Resize {
628                window_id,
629                width,
630                height,
631            } => (
632                "resize",
633                window_id.clone(),
634                json!({"width": width, "height": height}),
635            ),
636            Self::Move { window_id, x, y } => ("move", window_id.clone(), json!({"x": x, "y": y})),
637            Self::Maximize {
638                window_id,
639                maximized,
640            } => (
641                "maximize",
642                window_id.clone(),
643                json!({"maximized": maximized}),
644            ),
645            Self::Minimize {
646                window_id,
647                minimized,
648            } => (
649                "minimize",
650                window_id.clone(),
651                json!({"minimized": minimized}),
652            ),
653            Self::SetMode { window_id, mode } => (
654                "set_mode",
655                window_id.clone(),
656                json!({"mode": mode.to_string()}),
657            ),
658            Self::ToggleMaximize(id) => ("toggle_maximize", id.clone(), json!({})),
659            Self::ToggleDecorations(id) => ("toggle_decorations", id.clone(), json!({})),
660            Self::FocusWindow(id) => ("gain_focus", id.clone(), json!({})),
661            Self::SetLevel { window_id, level } => (
662                "set_level",
663                window_id.clone(),
664                json!({"level": level.to_string()}),
665            ),
666            Self::DragWindow(id) => ("drag", id.clone(), json!({})),
667            Self::DragResize {
668                window_id,
669                direction,
670            } => (
671                "drag_resize",
672                window_id.clone(),
673                json!({"direction": direction}),
674            ),
675            Self::RequestAttention { window_id, urgency } => {
676                let mut v = json!({});
677                if let Some(u) = urgency {
678                    v["urgency"] = json!(u);
679                }
680                ("request_attention", window_id.clone(), v)
681            }
682            Self::Screenshot { window_id, tag } => {
683                ("screenshot", window_id.clone(), json!({"tag": tag}))
684            }
685            Self::SetResizable {
686                window_id,
687                resizable,
688            } => (
689                "set_resizable",
690                window_id.clone(),
691                json!({"resizable": resizable}),
692            ),
693            Self::SetMinSize {
694                window_id,
695                width,
696                height,
697            } => (
698                "set_min_size",
699                window_id.clone(),
700                json!({"width": width, "height": height}),
701            ),
702            Self::SetMaxSize {
703                window_id,
704                width,
705                height,
706            } => (
707                "set_max_size",
708                window_id.clone(),
709                json!({"width": width, "height": height}),
710            ),
711            Self::EnableMousePassthrough(id) => {
712                ("mouse_passthrough", id.clone(), json!({"enabled": true}))
713            }
714            Self::DisableMousePassthrough(id) => {
715                ("mouse_passthrough", id.clone(), json!({"enabled": false}))
716            }
717            Self::ShowSystemMenu(id) => ("show_system_menu", id.clone(), json!({})),
718            Self::SetIcon {
719                window_id,
720                data,
721                width,
722                height,
723            } => {
724                use base64::Engine as _;
725                let b64 = base64::engine::general_purpose::STANDARD.encode(data);
726                (
727                    "set_icon",
728                    window_id.clone(),
729                    json!({"data": b64, "width": width, "height": height}),
730                )
731            }
732            Self::SetResizeIncrements {
733                window_id,
734                width,
735                height,
736            } => (
737                "set_resize_increments",
738                window_id.clone(),
739                json!({"width": width, "height": height}),
740            ),
741        }
742    }
743
744    /// Return the window ID this op targets, when one applies.
745    pub fn window_id(&self) -> Option<&str> {
746        match self {
747            Self::Open { window_id, .. }
748            | Self::Update { window_id, .. }
749            | Self::Resize { window_id, .. }
750            | Self::Move { window_id, .. }
751            | Self::Maximize { window_id, .. }
752            | Self::Minimize { window_id, .. }
753            | Self::SetMode { window_id, .. }
754            | Self::SetLevel { window_id, .. }
755            | Self::DragResize { window_id, .. }
756            | Self::RequestAttention { window_id, .. }
757            | Self::Screenshot { window_id, .. }
758            | Self::SetResizable { window_id, .. }
759            | Self::SetMinSize { window_id, .. }
760            | Self::SetMaxSize { window_id, .. }
761            | Self::SetIcon { window_id, .. }
762            | Self::SetResizeIncrements { window_id, .. } => Some(window_id),
763            Self::Close(id)
764            | Self::ToggleMaximize(id)
765            | Self::ToggleDecorations(id)
766            | Self::FocusWindow(id)
767            | Self::DragWindow(id)
768            | Self::EnableMousePassthrough(id)
769            | Self::DisableMousePassthrough(id)
770            | Self::ShowSystemMenu(id) => Some(id),
771        }
772    }
773}
774
775impl WindowQuery {
776    /// Build a typed [`WindowQuery`] from the wire-protocol `{op,
777    /// window_id, payload}` triple. Returns `None` for unrecognised
778    /// op strings.
779    pub fn from_wire(op: &str, window_id: &str, payload: &Value) -> Option<Self> {
780        let wid = window_id.to_string();
781        let tag = payload
782            .get("tag")
783            .and_then(|v| v.as_str())
784            .unwrap_or_default()
785            .to_string();
786        match op {
787            "get_size" => Some(Self::GetSize {
788                window_id: wid,
789                tag,
790            }),
791            "get_position" => Some(Self::GetPosition {
792                window_id: wid,
793                tag,
794            }),
795            "is_maximized" => Some(Self::IsMaximized {
796                window_id: wid,
797                tag,
798            }),
799            "is_minimized" => Some(Self::IsMinimized {
800                window_id: wid,
801                tag,
802            }),
803            "get_mode" => Some(Self::GetMode {
804                window_id: wid,
805                tag,
806            }),
807            "get_scale_factor" => Some(Self::GetScaleFactor {
808                window_id: wid,
809                tag,
810            }),
811            "monitor_size" => Some(Self::MonitorSize {
812                window_id: wid,
813                tag,
814            }),
815            "raw_id" => Some(Self::RawId {
816                window_id: wid,
817                tag,
818            }),
819            _ => None,
820        }
821    }
822
823    /// Emit the wire-protocol `(op, window_id, payload)` triple.
824    pub fn to_wire(&self) -> (&'static str, String, Value) {
825        use serde_json::json;
826        match self {
827            Self::GetSize { window_id, tag } => {
828                ("get_size", window_id.clone(), json!({"tag": tag}))
829            }
830            Self::GetPosition { window_id, tag } => {
831                ("get_position", window_id.clone(), json!({"tag": tag}))
832            }
833            Self::IsMaximized { window_id, tag } => {
834                ("is_maximized", window_id.clone(), json!({"tag": tag}))
835            }
836            Self::IsMinimized { window_id, tag } => {
837                ("is_minimized", window_id.clone(), json!({"tag": tag}))
838            }
839            Self::GetMode { window_id, tag } => {
840                ("get_mode", window_id.clone(), json!({"tag": tag}))
841            }
842            Self::GetScaleFactor { window_id, tag } => {
843                ("get_scale_factor", window_id.clone(), json!({"tag": tag}))
844            }
845            Self::MonitorSize { window_id, tag } => {
846                ("monitor_size", window_id.clone(), json!({"tag": tag}))
847            }
848            Self::RawId { window_id, tag } => ("raw_id", window_id.clone(), json!({"tag": tag})),
849        }
850    }
851
852    /// Return the window ID this query targets.
853    pub fn window_id(&self) -> &str {
854        match self {
855            Self::GetSize { window_id, .. }
856            | Self::GetPosition { window_id, .. }
857            | Self::IsMaximized { window_id, .. }
858            | Self::IsMinimized { window_id, .. }
859            | Self::GetMode { window_id, .. }
860            | Self::GetScaleFactor { window_id, .. }
861            | Self::MonitorSize { window_id, .. }
862            | Self::RawId { window_id, .. } => window_id,
863        }
864    }
865}
866
867// ---------------------------------------------------------------------------
868// System operations
869// ---------------------------------------------------------------------------
870
871/// A system-level operation.
872#[derive(Debug)]
873pub enum SystemOp {
874    /// Enable or disable automatic window tabbing (macOS).
875    AllowAutomaticTabbing(bool),
876}
877
878/// A system-level query.
879#[derive(Debug)]
880#[non_exhaustive]
881pub enum SystemQuery {
882    /// Query the current OS theme (light/dark).
883    GetTheme {
884        /// Correlation tag used for matching responses.
885        tag: String,
886    },
887    /// Query system information (OS, renderer version, etc.).
888    GetInfo {
889        /// Correlation tag used for matching responses.
890        tag: String,
891    },
892}
893
894impl SystemOp {
895    /// Build a typed [`SystemOp`] from the wire-protocol `(op, payload)`
896    /// pair. Returns `None` for unrecognised ops.
897    pub fn from_wire(op: &str, payload: &Value) -> Option<Self> {
898        match op {
899            "allow_automatic_tabbing" => {
900                let enabled = payload
901                    .get("enabled")
902                    .and_then(|v| v.as_bool())
903                    .unwrap_or(true);
904                Some(Self::AllowAutomaticTabbing(enabled))
905            }
906            _ => None,
907        }
908    }
909
910    /// Emit the wire-protocol `(op, payload)` pair.
911    pub fn to_wire(&self) -> (&'static str, Value) {
912        use serde_json::json;
913        match self {
914            Self::AllowAutomaticTabbing(enabled) => {
915                ("allow_automatic_tabbing", json!({"enabled": enabled}))
916            }
917        }
918    }
919}
920
921impl SystemQuery {
922    /// Build a typed [`SystemQuery`] from the wire-protocol `(op, payload)`
923    /// pair. Returns `None` for unrecognised ops.
924    pub fn from_wire(op: &str, payload: &Value) -> Option<Self> {
925        let tag = payload
926            .get("tag")
927            .and_then(|v| v.as_str())
928            .unwrap_or_default()
929            .to_string();
930        match op {
931            "get_system_theme" => Some(Self::GetTheme { tag }),
932            "get_system_info" => Some(Self::GetInfo { tag }),
933            _ => None,
934        }
935    }
936
937    /// Emit the wire-protocol `(op, payload)` pair.
938    pub fn to_wire(&self) -> (&'static str, Value) {
939        use serde_json::json;
940        match self {
941            Self::GetTheme { tag } => ("get_system_theme", json!({"tag": tag})),
942            Self::GetInfo { tag } => ("get_system_info", json!({"tag": tag})),
943        }
944    }
945}
946
947// ---------------------------------------------------------------------------
948// Effects
949// ---------------------------------------------------------------------------
950
951/// A platform effect request.
952#[derive(Debug)]
953pub enum EffectRequest {
954    /// File Open.
955    FileOpen(FileDialogOpts),
956    /// File Open Multiple.
957    FileOpenMultiple(FileDialogOpts),
958    /// File Save.
959    FileSave(FileDialogOpts),
960    /// Directory Select.
961    DirectorySelect(FileDialogOpts),
962    /// Directory Select Multiple.
963    DirectorySelectMultiple(FileDialogOpts),
964    /// Clipboard Read.
965    ClipboardRead,
966    /// Clipboard Write.
967    ClipboardWrite(String),
968    /// Clipboard Read Html.
969    ClipboardReadHtml,
970    /// Clipboard Write Html.
971    ClipboardWriteHtml {
972        /// HTML payload.
973        html: String,
974        /// Plain-text fallback for HTML clipboard writes.
975        alt_text: Option<String>,
976    },
977    /// Clipboard Clear.
978    ClipboardClear,
979    /// Clipboard Read Primary.
980    ClipboardReadPrimary,
981    /// Clipboard Write Primary.
982    ClipboardWritePrimary(String),
983    /// Notification.
984    Notification {
985        /// Human-readable title.
986        title: String,
987        /// Human-readable body text.
988        body: String,
989        /// Per-operation options.
990        opts: NotificationOpts,
991    },
992}
993
994impl EffectRequest {
995    /// The wire-format kind string for this effect request.
996    pub fn kind(&self) -> &'static str {
997        match self {
998            Self::FileOpen(_) => "file_open",
999            Self::FileOpenMultiple(_) => "file_open_multiple",
1000            Self::FileSave(_) => "file_save",
1001            Self::DirectorySelect(_) => "directory_select",
1002            Self::DirectorySelectMultiple(_) => "directory_select_multiple",
1003            Self::ClipboardRead => "clipboard_read",
1004            Self::ClipboardWrite(_) => "clipboard_write",
1005            Self::ClipboardReadHtml => "clipboard_read_html",
1006            Self::ClipboardWriteHtml { .. } => "clipboard_write_html",
1007            Self::ClipboardClear => "clipboard_clear",
1008            Self::ClipboardReadPrimary => "clipboard_read_primary",
1009            Self::ClipboardWritePrimary(_) => "clipboard_write_primary",
1010            Self::Notification { .. } => "notification",
1011        }
1012    }
1013}
1014
1015/// Returns true if `kind` is a built-in effect request kind.
1016pub fn is_known_effect_kind(kind: &str) -> bool {
1017    matches!(
1018        kind,
1019        "file_open"
1020            | "file_open_multiple"
1021            | "file_save"
1022            | "directory_select"
1023            | "directory_select_multiple"
1024            | "clipboard_read"
1025            | "clipboard_write"
1026            | "clipboard_read_html"
1027            | "clipboard_write_html"
1028            | "clipboard_clear"
1029            | "clipboard_read_primary"
1030            | "clipboard_write_primary"
1031            | "notification"
1032    )
1033}
1034
1035/// Why a wire effect request could not be parsed safely.
1036#[derive(Debug, Clone, PartialEq, Eq)]
1037pub enum EffectRequestValidationError {
1038    /// The effect kind is not built in.
1039    UnknownKind {
1040        /// Effect kind string from the request (unrecognized).
1041        kind: String,
1042    },
1043    /// The payload is not a JSON object.
1044    InvalidPayload {
1045        /// Effect kind string for which the payload was rejected.
1046        kind: String,
1047        /// Human-readable description of the expected payload shape.
1048        expected: &'static str,
1049    },
1050    /// A required field was absent.
1051    MissingField {
1052        /// Effect kind string the missing field belongs to.
1053        kind: String,
1054        /// Name of the absent field.
1055        field: &'static str,
1056    },
1057    /// A field was present with the wrong JSON type.
1058    InvalidFieldType {
1059        /// Effect kind string the field belongs to.
1060        kind: String,
1061        /// Name of the field with the wrong type.
1062        field: &'static str,
1063        /// Human-readable description of the expected JSON type.
1064        expected: &'static str,
1065    },
1066    /// A field had the right JSON type but not an accepted value.
1067    InvalidFieldValue {
1068        /// Effect kind string the field belongs to.
1069        kind: String,
1070        /// Name of the field with the rejected value.
1071        field: &'static str,
1072        /// Human-readable description of why the value was rejected.
1073        detail: String,
1074    },
1075}
1076
1077impl fmt::Display for EffectRequestValidationError {
1078    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1079        match self {
1080            Self::UnknownKind { kind } => write!(f, "unknown effect kind: {kind}"),
1081            Self::InvalidPayload { kind, expected } => {
1082                write!(f, "invalid payload for {kind}: expected {expected}")
1083            }
1084            Self::MissingField { kind, field } => {
1085                write!(f, "missing required field for {kind}: {field}")
1086            }
1087            Self::InvalidFieldType {
1088                kind,
1089                field,
1090                expected,
1091            } => write!(
1092                f,
1093                "invalid field type for {kind}.{field}: expected {expected}"
1094            ),
1095            Self::InvalidFieldValue {
1096                kind,
1097                field,
1098                detail,
1099            } => write!(f, "invalid field value for {kind}.{field}: {detail}"),
1100        }
1101    }
1102}
1103
1104impl std::error::Error for EffectRequestValidationError {}
1105
1106/// Options for file and directory dialogs.
1107#[derive(Debug, Default)]
1108pub struct FileDialogOpts {
1109    /// Dialog window title.
1110    pub title: Option<String>,
1111    /// Initial directory to open in.
1112    pub directory: Option<String>,
1113    /// File type filters as `(label, [extensions])` pairs.
1114    pub filters: Vec<(String, Vec<String>)>,
1115    /// Default file name for save dialogs.
1116    pub default_name: Option<String>,
1117}
1118
1119impl FileDialogOpts {
1120    /// Construct a new value.
1121    pub fn new() -> Self {
1122        Self::default()
1123    }
1124
1125    /// Set the dialog window title.
1126    pub fn title(mut self, title: &str) -> Self {
1127        self.title = Some(title.to_string());
1128        self
1129    }
1130
1131    /// Set the initial directory to open in.
1132    pub fn directory(mut self, dir: &str) -> Self {
1133        self.directory = Some(dir.to_string());
1134        self
1135    }
1136
1137    /// Add a file type filter (e.g. `("Images", &["png", "jpg"])`).
1138    pub fn filter(mut self, label: &str, extensions: &[&str]) -> Self {
1139        self.filters.push((
1140            label.to_string(),
1141            extensions.iter().map(|e| e.to_string()).collect(),
1142        ));
1143        self
1144    }
1145
1146    /// Set the default file name (for save dialogs).
1147    pub fn default_name(mut self, name: &str) -> Self {
1148        self.default_name = Some(name.to_string());
1149        self
1150    }
1151}
1152
1153/// Options for desktop notifications.
1154///
1155/// `timeout` travels over the wire as `u64` milliseconds. Values
1156/// beyond `u64::MAX` (~584 million years) saturate to `u64::MAX`
1157/// rather than wrapping; in practice every realistic duration fits.
1158#[derive(Debug, Default)]
1159pub struct NotificationOpts {
1160    /// Path or name of the notification icon.
1161    pub icon: Option<String>,
1162    /// How long the notification should be displayed.
1163    ///
1164    /// Encoded as `u64` milliseconds on the wire. Values larger than
1165    /// the `u64` millisecond range saturate to `u64::MAX`.
1166    pub timeout: Option<Duration>,
1167    /// Urgency level for the notification.
1168    pub urgency: Option<NotificationUrgency>,
1169    /// Sound name to play with the notification.
1170    pub sound: Option<String>,
1171}
1172
1173impl NotificationOpts {
1174    /// Construct a new value.
1175    pub fn new() -> Self {
1176        Self::default()
1177    }
1178
1179    /// Set the notification icon path or name.
1180    pub fn icon(mut self, icon: &str) -> Self {
1181        self.icon = Some(icon.to_string());
1182        self
1183    }
1184
1185    /// Set how long the notification should be displayed.
1186    pub fn timeout(mut self, timeout: Duration) -> Self {
1187        self.timeout = Some(timeout);
1188        self
1189    }
1190
1191    /// Set the urgency level.
1192    pub fn urgency(mut self, urgency: NotificationUrgency) -> Self {
1193        self.urgency = Some(urgency);
1194        self
1195    }
1196
1197    /// Set the sound name to play.
1198    pub fn sound(mut self, sound: &str) -> Self {
1199        self.sound = Some(sound.to_string());
1200        self
1201    }
1202}
1203
1204// ---------------------------------------------------------------------------
1205// Image operations
1206// ---------------------------------------------------------------------------
1207
1208/// An image management operation.
1209#[derive(Debug)]
1210#[non_exhaustive]
1211pub enum ImageOp {
1212    /// Create an image from encoded bytes (PNG, JPEG, etc.).
1213    Create {
1214        /// Handle.
1215        handle: String,
1216        /// Raw bytes (pixels, font, etc.).
1217        data: Vec<u8>,
1218    },
1219    /// Create an image from raw RGBA pixel data.
1220    CreateRaw {
1221        /// Handle.
1222        handle: String,
1223        /// Width in pixels.
1224        width: u32,
1225        /// Height in pixels.
1226        height: u32,
1227        /// Pixels.
1228        pixels: Vec<u8>,
1229    },
1230    /// Replace an existing image with new encoded bytes.
1231    Update {
1232        /// Handle.
1233        handle: String,
1234        /// Raw bytes (pixels, font, etc.).
1235        data: Vec<u8>,
1236    },
1237    /// Replace an existing image with new raw RGBA pixel data.
1238    UpdateRaw {
1239        /// Handle.
1240        handle: String,
1241        /// Width in pixels.
1242        width: u32,
1243        /// Height in pixels.
1244        height: u32,
1245        /// Pixels.
1246        pixels: Vec<u8>,
1247    },
1248    /// Delete an image by handle.
1249    Delete(String),
1250    /// List all loaded image handles.
1251    List {
1252        /// Correlation tag used for matching responses.
1253        tag: String,
1254    },
1255    /// Delete all loaded images.
1256    Clear,
1257}
1258
1259// ---------------------------------------------------------------------------
1260// Widget commands
1261// ---------------------------------------------------------------------------
1262
1263/// A single widget-targeted command.
1264///
1265/// Used as the element type for atomic widget batches
1266/// ([`RendererOp::Commands`]) and as the payload of single widget
1267/// commands built via [`RendererOp::Command`]. Construct via
1268/// [`WidgetCommand::new`] (typed) or [`WidgetCommand::raw`]
1269/// (family + value).
1270#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1271pub struct WidgetCommand {
1272    /// The target widget's scoped ID.
1273    pub id: String,
1274    /// The command family name.
1275    pub family: String,
1276    /// Command-specific data.
1277    #[serde(default)]
1278    pub value: Value,
1279}
1280
1281impl WidgetCommand {
1282    /// Build a typed widget command. The family name and wire value
1283    /// are derived from the typed command via
1284    /// [`WidgetCommandEncode`](crate::WidgetCommandEncode).
1285    pub fn new<C: crate::WidgetCommandEncode>(id: &str, cmd: C) -> Self {
1286        let (family, value) = cmd.to_wire();
1287        Self {
1288            id: id.to_string(),
1289            family: family.to_string(),
1290            value: Value::from(value),
1291        }
1292    }
1293
1294    /// Build a widget command from raw family string and value.
1295    pub fn raw(id: &str, family: &str, value: impl Into<Value>) -> Self {
1296        Self {
1297            id: id.to_string(),
1298            family: family.to_string(),
1299            value: value.into(),
1300        }
1301    }
1302}
1303
1304// ---------------------------------------------------------------------------
1305// Wire serialization helpers
1306// ---------------------------------------------------------------------------
1307
1308/// Convert an [`EffectRequest`] to the wire format `(kind, payload)`.
1309pub fn effect_request_to_wire(request: &EffectRequest) -> (&'static str, Value) {
1310    use serde_json::json;
1311    match request {
1312        EffectRequest::FileOpen(opts) => ("file_open", file_dialog_opts_to_value(opts)),
1313        EffectRequest::FileOpenMultiple(opts) => {
1314            ("file_open_multiple", file_dialog_opts_to_value(opts))
1315        }
1316        EffectRequest::FileSave(opts) => ("file_save", file_dialog_opts_to_value(opts)),
1317        EffectRequest::DirectorySelect(opts) => {
1318            ("directory_select", file_dialog_opts_to_value(opts))
1319        }
1320        EffectRequest::DirectorySelectMultiple(opts) => {
1321            ("directory_select_multiple", file_dialog_opts_to_value(opts))
1322        }
1323        EffectRequest::ClipboardRead => ("clipboard_read", json!({})),
1324        EffectRequest::ClipboardWrite(text) => ("clipboard_write", json!({"text": text})),
1325        EffectRequest::ClipboardReadHtml => ("clipboard_read_html", json!({})),
1326        EffectRequest::ClipboardWriteHtml { html, alt_text } => {
1327            let mut payload = json!({"html": html});
1328            if let Some(alt) = alt_text {
1329                payload["alt_text"] = json!(alt);
1330            }
1331            ("clipboard_write_html", payload)
1332        }
1333        EffectRequest::ClipboardClear => ("clipboard_clear", json!({})),
1334        EffectRequest::ClipboardReadPrimary => ("clipboard_read_primary", json!({})),
1335        EffectRequest::ClipboardWritePrimary(text) => {
1336            ("clipboard_write_primary", json!({"text": text}))
1337        }
1338        EffectRequest::Notification { title, body, opts } => {
1339            let mut payload = json!({"title": title, "body": body});
1340            if let Some(ref icon) = opts.icon {
1341                payload["icon"] = json!(icon);
1342            }
1343            if let Some(ref timeout) = opts.timeout {
1344                payload["timeout"] = json!(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX));
1345            }
1346            if let Some(ref urgency) = opts.urgency {
1347                payload["urgency"] = json!(urgency);
1348            }
1349            if let Some(ref sound) = opts.sound {
1350                payload["sound"] = json!(sound);
1351            }
1352            ("notification", payload)
1353        }
1354    }
1355}
1356
1357fn file_dialog_opts_to_value(opts: &FileDialogOpts) -> Value {
1358    use serde_json::json;
1359    let mut payload = json!({});
1360    if let Some(ref title) = opts.title {
1361        payload["title"] = json!(title);
1362    }
1363    if let Some(ref dir) = opts.directory {
1364        payload["directory"] = json!(dir);
1365    }
1366    if !opts.filters.is_empty() {
1367        let filters: Vec<Value> = opts
1368            .filters
1369            .iter()
1370            .map(|(label, exts)| json!([label, exts.join(";")]))
1371            .collect();
1372        payload["filters"] = json!(filters);
1373    }
1374    if let Some(ref name) = opts.default_name {
1375        payload["default_name"] = json!(name);
1376    }
1377    payload
1378}
1379
1380/// Convert wire format `(kind, payload)` to an [`EffectRequest`],
1381/// rejecting unknown kinds and malformed payloads.
1382///
1383/// # Errors
1384///
1385/// Returns [`EffectRequestValidationError`] when the kind is unknown,
1386/// the payload is not an object, or required fields are missing or
1387/// malformed.
1388pub fn validate_effect_request_from_wire(
1389    kind: &str,
1390    payload: &Value,
1391) -> Result<EffectRequest, EffectRequestValidationError> {
1392    if !is_known_effect_kind(kind) {
1393        return Err(EffectRequestValidationError::UnknownKind {
1394            kind: kind.to_string(),
1395        });
1396    }
1397    let fields = payload_fields(kind, payload)?;
1398    match kind {
1399        "file_open" => Ok(EffectRequest::FileOpen(file_dialog_opts_from_fields(
1400            kind, fields,
1401        )?)),
1402        "file_open_multiple" => Ok(EffectRequest::FileOpenMultiple(
1403            file_dialog_opts_from_fields(kind, fields)?,
1404        )),
1405        "file_save" => Ok(EffectRequest::FileSave(file_dialog_opts_from_fields(
1406            kind, fields,
1407        )?)),
1408        "directory_select" => Ok(EffectRequest::DirectorySelect(
1409            file_dialog_opts_from_fields(kind, fields)?,
1410        )),
1411        "directory_select_multiple" => Ok(EffectRequest::DirectorySelectMultiple(
1412            file_dialog_opts_from_fields(kind, fields)?,
1413        )),
1414        "clipboard_read" => Ok(EffectRequest::ClipboardRead),
1415        "clipboard_write" => {
1416            let text = required_string_field(kind, fields, "text")?;
1417            Ok(EffectRequest::ClipboardWrite(text))
1418        }
1419        "clipboard_read_html" => Ok(EffectRequest::ClipboardReadHtml),
1420        "clipboard_write_html" => {
1421            let html = required_string_field(kind, fields, "html")?;
1422            let alt_text = optional_string_field(kind, fields, "alt_text")?;
1423            Ok(EffectRequest::ClipboardWriteHtml { html, alt_text })
1424        }
1425        "clipboard_clear" => Ok(EffectRequest::ClipboardClear),
1426        "clipboard_read_primary" => Ok(EffectRequest::ClipboardReadPrimary),
1427        "clipboard_write_primary" => {
1428            let text = required_string_field(kind, fields, "text")?;
1429            Ok(EffectRequest::ClipboardWritePrimary(text))
1430        }
1431        "notification" => {
1432            let title = required_string_field(kind, fields, "title")?;
1433            let body = required_string_field(kind, fields, "body")?;
1434            let opts = NotificationOpts {
1435                icon: optional_string_field(kind, fields, "icon")?,
1436                timeout: optional_u64_field(kind, fields, "timeout")?.map(Duration::from_millis),
1437                urgency: optional_urgency_field(kind, fields)?,
1438                sound: optional_string_field(kind, fields, "sound")?,
1439            };
1440            Ok(EffectRequest::Notification { title, body, opts })
1441        }
1442        _ => unreachable!("effect kind was checked before parsing"),
1443    }
1444}
1445
1446/// Convert wire format `(kind, payload)` to an [`EffectRequest`].
1447///
1448/// Returns `None` for unrecognized kinds or invalid payloads.
1449pub fn effect_request_from_wire(kind: &str, payload: &Value) -> Option<EffectRequest> {
1450    validate_effect_request_from_wire(kind, payload).ok()
1451}
1452
1453// ---------------------------------------------------------------------------
1454// PlushieType impls for operation enums
1455// ---------------------------------------------------------------------------
1456
1457impl crate::types::PlushieType for WindowLevel {
1458    fn wire_decode(value: &Value) -> Option<Self> {
1459        match value.as_str()? {
1460            "normal" => Some(Self::Normal),
1461            "always_on_top" => Some(Self::AlwaysOnTop),
1462            "always_on_bottom" => Some(Self::AlwaysOnBottom),
1463            _ => None,
1464        }
1465    }
1466
1467    fn wire_encode(&self) -> crate::protocol::PropValue {
1468        crate::protocol::PropValue::Str(
1469            match self {
1470                Self::Normal => "normal",
1471                Self::AlwaysOnTop => "always_on_top",
1472                Self::AlwaysOnBottom => "always_on_bottom",
1473            }
1474            .into(),
1475        )
1476    }
1477
1478    fn type_name() -> &'static str {
1479        "window_level"
1480    }
1481}
1482
1483fn payload_fields<'a>(
1484    kind: &str,
1485    payload: &'a Value,
1486) -> Result<&'a Map<String, Value>, EffectRequestValidationError> {
1487    payload
1488        .as_object()
1489        .ok_or_else(|| EffectRequestValidationError::InvalidPayload {
1490            kind: kind.to_string(),
1491            expected: "object",
1492        })
1493}
1494
1495fn required_string_field(
1496    kind: &str,
1497    fields: &Map<String, Value>,
1498    field: &'static str,
1499) -> Result<String, EffectRequestValidationError> {
1500    match fields.get(field) {
1501        Some(value) => value.as_str().map(ToString::to_string).ok_or_else(|| {
1502            EffectRequestValidationError::InvalidFieldType {
1503                kind: kind.to_string(),
1504                field,
1505                expected: "string",
1506            }
1507        }),
1508        None => Err(EffectRequestValidationError::MissingField {
1509            kind: kind.to_string(),
1510            field,
1511        }),
1512    }
1513}
1514
1515fn optional_string_field(
1516    kind: &str,
1517    fields: &Map<String, Value>,
1518    field: &'static str,
1519) -> Result<Option<String>, EffectRequestValidationError> {
1520    match fields.get(field) {
1521        Some(value) => value.as_str().map(|s| Some(s.to_string())).ok_or_else(|| {
1522            EffectRequestValidationError::InvalidFieldType {
1523                kind: kind.to_string(),
1524                field,
1525                expected: "string",
1526            }
1527        }),
1528        None => Ok(None),
1529    }
1530}
1531
1532fn optional_u64_field(
1533    kind: &str,
1534    fields: &Map<String, Value>,
1535    field: &'static str,
1536) -> Result<Option<u64>, EffectRequestValidationError> {
1537    match fields.get(field) {
1538        Some(value) => {
1539            value
1540                .as_u64()
1541                .map(Some)
1542                .ok_or_else(|| EffectRequestValidationError::InvalidFieldType {
1543                    kind: kind.to_string(),
1544                    field,
1545                    expected: "unsigned integer",
1546                })
1547        }
1548        None => Ok(None),
1549    }
1550}
1551
1552fn optional_urgency_field(
1553    kind: &str,
1554    fields: &Map<String, Value>,
1555) -> Result<Option<NotificationUrgency>, EffectRequestValidationError> {
1556    let Some(value) = fields.get("urgency") else {
1557        return Ok(None);
1558    };
1559    let Some(urgency) = value.as_str() else {
1560        return Err(EffectRequestValidationError::InvalidFieldType {
1561            kind: kind.to_string(),
1562            field: "urgency",
1563            expected: "string",
1564        });
1565    };
1566    match urgency {
1567        "low" => Ok(Some(NotificationUrgency::Low)),
1568        "normal" => Ok(Some(NotificationUrgency::Normal)),
1569        "critical" => Ok(Some(NotificationUrgency::Critical)),
1570        _ => Err(EffectRequestValidationError::InvalidFieldValue {
1571            kind: kind.to_string(),
1572            field: "urgency",
1573            detail: "expected low, normal, or critical".to_string(),
1574        }),
1575    }
1576}
1577
1578fn file_dialog_opts_from_fields(
1579    kind: &str,
1580    fields: &Map<String, Value>,
1581) -> Result<FileDialogOpts, EffectRequestValidationError> {
1582    Ok(FileDialogOpts {
1583        title: optional_string_field(kind, fields, "title")?,
1584        directory: optional_string_field(kind, fields, "directory")?,
1585        default_name: optional_string_field(kind, fields, "default_name")?,
1586        filters: file_dialog_filters_from_fields(kind, fields)?,
1587    })
1588}
1589
1590fn file_dialog_filters_from_fields(
1591    kind: &str,
1592    fields: &Map<String, Value>,
1593) -> Result<Vec<(String, Vec<String>)>, EffectRequestValidationError> {
1594    let Some(value) = fields.get("filters") else {
1595        return Ok(Vec::new());
1596    };
1597    let filters =
1598        value
1599            .as_array()
1600            .ok_or_else(|| EffectRequestValidationError::InvalidFieldType {
1601                kind: kind.to_string(),
1602                field: "filters",
1603                expected: "array",
1604            })?;
1605    let mut parsed = Vec::new();
1606    for filter in filters {
1607        let pair =
1608            filter
1609                .as_array()
1610                .ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
1611                    kind: kind.to_string(),
1612                    field: "filters",
1613                    detail: "each filter must be [name, extensions]".to_string(),
1614                })?;
1615        if pair.len() < 2 {
1616            return Err(EffectRequestValidationError::InvalidFieldValue {
1617                kind: kind.to_string(),
1618                field: "filters",
1619                detail: "each filter must include a name and extensions".to_string(),
1620            });
1621        }
1622        let name =
1623            pair[0]
1624                .as_str()
1625                .ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
1626                    kind: kind.to_string(),
1627                    field: "filters",
1628                    detail: "filter name must be a string".to_string(),
1629                })?;
1630        let ext =
1631            pair[1]
1632                .as_str()
1633                .ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
1634                    kind: kind.to_string(),
1635                    field: "filters",
1636                    detail: "filter extensions must be a string".to_string(),
1637                })?;
1638        let extensions: Vec<String> = ext
1639            .split(';')
1640            .map(|e| e.trim().trim_start_matches("*.").to_string())
1641            .collect();
1642        parsed.push((name.to_string(), extensions));
1643    }
1644    Ok(parsed)
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650    use serde_json::json;
1651
1652    #[test]
1653    fn effect_parser_rejects_missing_required_field() {
1654        let err = validate_effect_request_from_wire("clipboard_write", &json!({})).unwrap_err();
1655
1656        assert_eq!(
1657            err,
1658            EffectRequestValidationError::MissingField {
1659                kind: "clipboard_write".to_string(),
1660                field: "text",
1661            }
1662        );
1663    }
1664
1665    #[test]
1666    fn effect_parser_rejects_unknown_kind() {
1667        let err = validate_effect_request_from_wire("not_real", &json!({})).unwrap_err();
1668
1669        assert_eq!(
1670            err,
1671            EffectRequestValidationError::UnknownKind {
1672                kind: "not_real".to_string(),
1673            }
1674        );
1675    }
1676
1677    #[test]
1678    fn effect_parser_rejects_wrong_typed_required_field() {
1679        let err =
1680            validate_effect_request_from_wire("notification", &json!({"title": 1, "body": "hi"}))
1681                .unwrap_err();
1682
1683        assert_eq!(
1684            err,
1685            EffectRequestValidationError::InvalidFieldType {
1686                kind: "notification".to_string(),
1687                field: "title",
1688                expected: "string",
1689            }
1690        );
1691    }
1692
1693    #[test]
1694    fn effect_parser_rejects_wrong_typed_optional_field() {
1695        let err = validate_effect_request_from_wire(
1696            "clipboard_write_html",
1697            &json!({"html": "<b>hi</b>", "alt_text": false}),
1698        )
1699        .unwrap_err();
1700
1701        assert_eq!(
1702            err,
1703            EffectRequestValidationError::InvalidFieldType {
1704                kind: "clipboard_write_html".to_string(),
1705                field: "alt_text",
1706                expected: "string",
1707            }
1708        );
1709    }
1710
1711    #[test]
1712    fn effect_parser_rejects_invalid_file_dialog_filters() {
1713        let err = validate_effect_request_from_wire(
1714            "file_open",
1715            &json!({"filters": [{"name": "Images", "extensions": "png"}]}),
1716        )
1717        .unwrap_err();
1718
1719        assert!(matches!(
1720            err,
1721            EffectRequestValidationError::InvalidFieldValue {
1722                kind,
1723                field: "filters",
1724                ..
1725            } if kind == "file_open"
1726        ));
1727    }
1728
1729    #[test]
1730    fn effect_parser_parses_valid_required_fields() {
1731        let request = validate_effect_request_from_wire(
1732            "notification",
1733            &json!({
1734                "title": "Build done",
1735                "body": "All checks passed",
1736                "timeout": 1500,
1737                "urgency": "normal",
1738            }),
1739        )
1740        .unwrap();
1741
1742        match request {
1743            EffectRequest::Notification { title, body, opts } => {
1744                assert_eq!(title, "Build done");
1745                assert_eq!(body, "All checks passed");
1746                assert_eq!(opts.timeout, Some(Duration::from_millis(1500)));
1747                assert_eq!(opts.urgency, Some(NotificationUrgency::Normal));
1748            }
1749            other => panic!("expected notification, got {other:?}"),
1750        }
1751    }
1752
1753    // -----------------------------------------------------------------------
1754    // WindowOp wire round-trips
1755    //
1756    // Each variant goes through the (op, window_id, payload) <-> typed
1757    // pair. The renderer parses the wire shape with `from_wire`, the SDK
1758    // emits it via `to_wire`. Drift between the two would silently drop
1759    // commands or misroute window IDs, so every variant gets an explicit
1760    // round-trip pin here. Variants with parameters check field-by-field;
1761    // the unit-window-id variants check the op string and id only.
1762    // -----------------------------------------------------------------------
1763
1764    fn window_op_round_trip(op: WindowOp) {
1765        let (op_str, wid, payload) = op.to_wire();
1766        let parsed = WindowOp::from_wire(op_str, &wid, &payload)
1767            .unwrap_or_else(|| panic!("WindowOp::from_wire returned None for op={op_str}"));
1768        // Re-serialize the parsed op and compare to the original wire
1769        // shape; this catches asymmetric (de)serializers without needing
1770        // PartialEq on every typed variant.
1771        let (re_op_str, re_wid, re_payload) = parsed.to_wire();
1772        assert_eq!(op_str, re_op_str, "op string drift");
1773        assert_eq!(wid, re_wid, "window_id drift");
1774        assert_eq!(payload, re_payload, "payload drift");
1775    }
1776
1777    #[test]
1778    fn window_op_open_round_trips() {
1779        window_op_round_trip(WindowOp::Open {
1780            window_id: "main".into(),
1781            settings: json!({"title": "App", "size": [800, 600]}),
1782        });
1783    }
1784
1785    #[test]
1786    fn window_op_update_round_trips() {
1787        window_op_round_trip(WindowOp::Update {
1788            window_id: "main".into(),
1789            settings: json!({"title": "Renamed"}),
1790        });
1791    }
1792
1793    #[test]
1794    fn window_op_close_round_trips() {
1795        window_op_round_trip(WindowOp::Close("popup".into()));
1796    }
1797
1798    #[test]
1799    fn window_op_resize_round_trips() {
1800        window_op_round_trip(WindowOp::Resize {
1801            window_id: "main".into(),
1802            width: 800.0,
1803            height: 600.0,
1804        });
1805    }
1806
1807    #[test]
1808    fn window_op_move_round_trips() {
1809        window_op_round_trip(WindowOp::Move {
1810            window_id: "main".into(),
1811            x: 100.0,
1812            y: 200.0,
1813        });
1814    }
1815
1816    #[test]
1817    fn window_op_maximize_round_trips() {
1818        window_op_round_trip(WindowOp::Maximize {
1819            window_id: "main".into(),
1820            maximized: true,
1821        });
1822    }
1823
1824    #[test]
1825    fn window_op_minimize_round_trips() {
1826        window_op_round_trip(WindowOp::Minimize {
1827            window_id: "main".into(),
1828            minimized: false,
1829        });
1830    }
1831
1832    #[test]
1833    fn window_op_set_mode_round_trips() {
1834        window_op_round_trip(WindowOp::SetMode {
1835            window_id: "main".into(),
1836            mode: WindowMode::Fullscreen,
1837        });
1838        window_op_round_trip(WindowOp::SetMode {
1839            window_id: "main".into(),
1840            mode: WindowMode::Windowed,
1841        });
1842    }
1843
1844    #[test]
1845    fn window_op_unit_variants_round_trip() {
1846        for op in [
1847            WindowOp::ToggleMaximize("main".into()),
1848            WindowOp::ToggleDecorations("main".into()),
1849            WindowOp::FocusWindow("main".into()),
1850            WindowOp::DragWindow("main".into()),
1851            WindowOp::EnableMousePassthrough("main".into()),
1852            WindowOp::DisableMousePassthrough("main".into()),
1853            WindowOp::ShowSystemMenu("main".into()),
1854        ] {
1855            window_op_round_trip(op);
1856        }
1857    }
1858
1859    #[test]
1860    fn window_op_set_level_round_trips() {
1861        for level in [
1862            WindowLevel::Normal,
1863            WindowLevel::AlwaysOnTop,
1864            WindowLevel::AlwaysOnBottom,
1865        ] {
1866            window_op_round_trip(WindowOp::SetLevel {
1867                window_id: "main".into(),
1868                level,
1869            });
1870        }
1871    }
1872
1873    #[test]
1874    fn window_op_drag_resize_round_trips() {
1875        window_op_round_trip(WindowOp::DragResize {
1876            window_id: "main".into(),
1877            direction: "north_east".into(),
1878        });
1879    }
1880
1881    #[test]
1882    fn window_op_request_attention_round_trips() {
1883        for urgency in [
1884            None,
1885            Some(NotificationUrgency::Low),
1886            Some(NotificationUrgency::Normal),
1887            Some(NotificationUrgency::Critical),
1888        ] {
1889            window_op_round_trip(WindowOp::RequestAttention {
1890                window_id: "main".into(),
1891                urgency,
1892            });
1893        }
1894    }
1895
1896    #[test]
1897    fn window_op_screenshot_round_trips() {
1898        window_op_round_trip(WindowOp::Screenshot {
1899            window_id: "main".into(),
1900            tag: "snap".into(),
1901        });
1902    }
1903
1904    #[test]
1905    fn window_op_resizable_min_max_round_trip() {
1906        window_op_round_trip(WindowOp::SetResizable {
1907            window_id: "main".into(),
1908            resizable: true,
1909        });
1910        window_op_round_trip(WindowOp::SetMinSize {
1911            window_id: "main".into(),
1912            width: 320.0,
1913            height: 240.0,
1914        });
1915        window_op_round_trip(WindowOp::SetMaxSize {
1916            window_id: "main".into(),
1917            width: 1920.0,
1918            height: 1080.0,
1919        });
1920        window_op_round_trip(WindowOp::SetResizeIncrements {
1921            window_id: "main".into(),
1922            width: 8.0,
1923            height: 8.0,
1924        });
1925    }
1926
1927    #[test]
1928    fn window_op_set_icon_round_trips_with_base64() {
1929        // SetIcon is the load-bearing variant: from_wire base64-decodes
1930        // the data field; to_wire base64-encodes it back. A regression
1931        // would silently produce a zero-byte icon.
1932        let bytes = vec![0xAA_u8, 0xBB, 0xCC, 0xDD];
1933        window_op_round_trip(WindowOp::SetIcon {
1934            window_id: "main".into(),
1935            data: bytes,
1936            width: 16,
1937            height: 16,
1938        });
1939    }
1940
1941    #[test]
1942    fn window_op_set_icon_invalid_base64_returns_none() {
1943        // Bad base64 must surface as "unrecognised" rather than as a
1944        // zero-byte icon; the caller logs a diagnostic and skips.
1945        let payload = json!({"data": "***not-base64***", "width": 16, "height": 16});
1946        assert!(WindowOp::from_wire("set_icon", "main", &payload).is_none());
1947    }
1948
1949    #[test]
1950    fn window_op_unknown_op_returns_none() {
1951        // Unrecognised op strings are surfaced as None so the renderer
1952        // can log and continue rather than swallow the message.
1953        let payload = json!({});
1954        assert!(WindowOp::from_wire("not_a_real_op", "main", &payload).is_none());
1955    }
1956
1957    #[test]
1958    fn window_op_resize_uses_payload_defaults_when_fields_missing() {
1959        // Fields default through the typed `f` extractor when the
1960        // payload is incomplete. The renderer's window manager uses
1961        // these defaults (800x600) for resize.
1962        let parsed = WindowOp::from_wire("resize", "main", &json!({})).unwrap();
1963        match parsed {
1964            WindowOp::Resize { width, height, .. } => {
1965                assert_eq!(width, 800.0);
1966                assert_eq!(height, 600.0);
1967            }
1968            other => panic!("expected Resize, got {other:?}"),
1969        }
1970    }
1971
1972    #[test]
1973    fn window_op_window_id_accessor_returns_target() {
1974        // `window_id()` lets dispatchers route on a shared field
1975        // regardless of which variant payload they hold; verify a
1976        // representative case from each shape (struct vs tuple).
1977        assert_eq!(
1978            WindowOp::Resize {
1979                window_id: "main".into(),
1980                width: 0.0,
1981                height: 0.0,
1982            }
1983            .window_id(),
1984            Some("main"),
1985        );
1986        assert_eq!(WindowOp::Close("popup".into()).window_id(), Some("popup"),);
1987    }
1988
1989    // -----------------------------------------------------------------------
1990    // WindowQuery wire round-trips
1991    // -----------------------------------------------------------------------
1992
1993    fn window_query_round_trip(q: WindowQuery) {
1994        let (op_str, wid, payload) = q.to_wire();
1995        let parsed = WindowQuery::from_wire(op_str, &wid, &payload)
1996            .unwrap_or_else(|| panic!("WindowQuery::from_wire returned None for op={op_str}"));
1997        let (re_op_str, re_wid, re_payload) = parsed.to_wire();
1998        assert_eq!(op_str, re_op_str);
1999        assert_eq!(wid, re_wid);
2000        assert_eq!(payload, re_payload);
2001    }
2002
2003    #[test]
2004    fn window_query_all_variants_round_trip() {
2005        let make = |build: fn(String, String) -> WindowQuery| build("main".into(), "tag1".into());
2006        for q in [
2007            make(|window_id, tag| WindowQuery::GetSize { window_id, tag }),
2008            make(|window_id, tag| WindowQuery::GetPosition { window_id, tag }),
2009            make(|window_id, tag| WindowQuery::IsMaximized { window_id, tag }),
2010            make(|window_id, tag| WindowQuery::IsMinimized { window_id, tag }),
2011            make(|window_id, tag| WindowQuery::GetMode { window_id, tag }),
2012            make(|window_id, tag| WindowQuery::GetScaleFactor { window_id, tag }),
2013            make(|window_id, tag| WindowQuery::MonitorSize { window_id, tag }),
2014            make(|window_id, tag| WindowQuery::RawId { window_id, tag }),
2015        ] {
2016            window_query_round_trip(q);
2017        }
2018    }
2019
2020    #[test]
2021    fn window_query_unknown_op_returns_none() {
2022        assert!(WindowQuery::from_wire("not_a_query", "main", &json!({})).is_none());
2023    }
2024
2025    // -----------------------------------------------------------------------
2026    // SystemOp / SystemQuery wire round-trips
2027    // -----------------------------------------------------------------------
2028
2029    #[test]
2030    fn system_op_allow_automatic_tabbing_round_trips() {
2031        let op = SystemOp::AllowAutomaticTabbing(true);
2032        let (op_str, payload) = op.to_wire();
2033        assert_eq!(op_str, "allow_automatic_tabbing");
2034        assert_eq!(payload, json!({"enabled": true}));
2035        match SystemOp::from_wire(op_str, &payload).unwrap() {
2036            SystemOp::AllowAutomaticTabbing(enabled) => assert!(enabled),
2037        }
2038    }
2039
2040    #[test]
2041    fn system_op_allow_automatic_tabbing_default_when_missing() {
2042        // The wire contract defaults `enabled` to `true` when the
2043        // field is absent; that's the documented "request enable"
2044        // shorthand and the check pins it.
2045        match SystemOp::from_wire("allow_automatic_tabbing", &json!({})).unwrap() {
2046            SystemOp::AllowAutomaticTabbing(enabled) => assert!(enabled),
2047        }
2048    }
2049
2050    #[test]
2051    fn system_op_unknown_op_returns_none() {
2052        assert!(SystemOp::from_wire("not_a_real_system_op", &json!({})).is_none());
2053    }
2054
2055    #[test]
2056    fn system_query_get_theme_round_trips() {
2057        let q = SystemQuery::GetTheme { tag: "t1".into() };
2058        let (op_str, payload) = q.to_wire();
2059        assert_eq!(op_str, "get_system_theme");
2060        assert_eq!(payload, json!({"tag": "t1"}));
2061        match SystemQuery::from_wire(op_str, &payload).unwrap() {
2062            SystemQuery::GetTheme { tag } => assert_eq!(tag, "t1"),
2063            other => panic!("expected GetTheme, got {other:?}"),
2064        }
2065    }
2066
2067    #[test]
2068    fn system_query_get_info_round_trips() {
2069        let q = SystemQuery::GetInfo { tag: "info".into() };
2070        let (op_str, payload) = q.to_wire();
2071        assert_eq!(op_str, "get_system_info");
2072        match SystemQuery::from_wire(op_str, &payload).unwrap() {
2073            SystemQuery::GetInfo { tag } => assert_eq!(tag, "info"),
2074            other => panic!("expected GetInfo, got {other:?}"),
2075        }
2076    }
2077
2078    #[test]
2079    fn system_query_unknown_op_returns_none() {
2080        assert!(SystemQuery::from_wire("not_a_query", &json!({})).is_none());
2081    }
2082
2083    #[test]
2084    fn effect_parser_round_trips_typed_requests() {
2085        let requests = vec![
2086            EffectRequest::FileOpen(
2087                FileDialogOpts::new()
2088                    .title("Open")
2089                    .filter("Images", &["png"]),
2090            ),
2091            EffectRequest::FileOpenMultiple(FileDialogOpts::new()),
2092            EffectRequest::FileSave(FileDialogOpts::new().default_name("note.txt")),
2093            EffectRequest::DirectorySelect(FileDialogOpts::new().directory("/tmp")),
2094            EffectRequest::DirectorySelectMultiple(FileDialogOpts::new()),
2095            EffectRequest::ClipboardRead,
2096            EffectRequest::ClipboardWrite("hello".to_string()),
2097            EffectRequest::ClipboardReadHtml,
2098            EffectRequest::ClipboardWriteHtml {
2099                html: "<b>hello</b>".to_string(),
2100                alt_text: Some("hello".to_string()),
2101            },
2102            EffectRequest::ClipboardClear,
2103            EffectRequest::ClipboardReadPrimary,
2104            EffectRequest::ClipboardWritePrimary("hello".to_string()),
2105            EffectRequest::Notification {
2106                title: "Done".to_string(),
2107                body: "Saved".to_string(),
2108                opts: NotificationOpts::new()
2109                    .icon("plushie")
2110                    .timeout(Duration::from_secs(1))
2111                    .urgency(NotificationUrgency::Low)
2112                    .sound("ding"),
2113            },
2114        ];
2115
2116        for request in requests {
2117            let (kind, payload) = effect_request_to_wire(&request);
2118            let parsed = validate_effect_request_from_wire(kind, &payload)
2119                .unwrap_or_else(|err| panic!("{kind} failed to parse: {err}"));
2120            assert_eq!(parsed.kind(), kind);
2121        }
2122    }
2123}