1use std::fmt;
8use std::time::Duration;
9
10use serde_json::Map;
11use serde_json::Value;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum WindowMode {
21 Windowed,
23 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum WindowLevel {
40 Normal,
42 AlwaysOnTop,
44 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum NotificationUrgency {
62 Low,
64 Normal,
66 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#[derive(Debug)]
89#[non_exhaustive]
90pub enum RendererOp {
91 Command {
98 id: String,
100 family: String,
102 value: Value,
104 },
105 Commands(Vec<WidgetCommand>),
107
108 FocusNext,
111 FocusPrevious,
113 FocusNextWithin {
119 scope: String,
121 },
122 FocusPreviousWithin {
125 scope: String,
127 },
128
129 Window(WindowOp),
132 WindowQuery(WindowQuery),
134
135 SystemOp(SystemOp),
138 SystemQuery(SystemQuery),
140
141 Effect {
144 tag: String,
146 request: EffectRequest,
148 timeout: Option<Duration>,
151 },
152
153 Image(ImageOp),
156
157 Announce {
166 text: String,
168 politeness: crate::types::Live,
170 },
171 LoadFont {
179 family: String,
181 bytes: Vec<u8>,
183 },
184
185 Subscribe {
188 kind: String,
190 tag: String,
192 max_rate: Option<u32>,
194 window_id: Option<String>,
196 },
197 Unsubscribe {
199 kind: String,
201 tag: String,
203 },
204
205 TreeHash {
208 tag: String,
210 },
211 FindFocused {
213 tag: String,
215 },
216 AdvanceFrame {
222 timestamp: u64,
224 },
225}
226
227#[derive(Debug)]
238#[non_exhaustive]
239pub enum WindowOp {
240 Open {
248 window_id: String,
250 settings: Value,
252 },
253 Update {
259 window_id: String,
261 settings: Value,
263 },
264 Close(String),
266 Resize {
268 window_id: String,
270 width: f32,
272 height: f32,
274 },
275 Move {
277 window_id: String,
279 x: f32,
281 y: f32,
283 },
284 Maximize {
286 window_id: String,
288 maximized: bool,
290 },
291 Minimize {
293 window_id: String,
295 minimized: bool,
297 },
298 SetMode {
300 window_id: String,
302 mode: WindowMode,
304 },
305 ToggleMaximize(String),
307 ToggleDecorations(String),
309 FocusWindow(String),
311 SetLevel {
313 window_id: String,
315 level: WindowLevel,
317 },
318 DragWindow(String),
320 DragResize {
322 window_id: String,
324 direction: String,
326 },
327 RequestAttention {
329 window_id: String,
331 urgency: Option<NotificationUrgency>,
333 },
334 Screenshot {
336 window_id: String,
338 tag: String,
340 },
341 SetResizable {
343 window_id: String,
345 resizable: bool,
347 },
348 SetMinSize {
350 window_id: String,
352 width: f32,
354 height: f32,
356 },
357 SetMaxSize {
359 window_id: String,
361 width: f32,
363 height: f32,
365 },
366 EnableMousePassthrough(String),
368 DisableMousePassthrough(String),
370 ShowSystemMenu(String),
372 SetIcon {
374 window_id: String,
376 data: Vec<u8>,
378 width: u32,
380 height: u32,
382 },
383 SetResizeIncrements {
385 window_id: String,
387 width: f32,
389 height: f32,
391 },
392}
393
394#[derive(Debug)]
396#[non_exhaustive]
397pub enum WindowQuery {
398 GetSize {
400 window_id: String,
402 tag: String,
404 },
405 GetPosition {
407 window_id: String,
409 tag: String,
411 },
412 IsMaximized {
414 window_id: String,
416 tag: String,
418 },
419 IsMinimized {
421 window_id: String,
423 tag: String,
425 },
426 GetMode {
428 window_id: String,
430 tag: String,
432 },
433 GetScaleFactor {
435 window_id: String,
437 tag: String,
439 },
440 MonitorSize {
442 window_id: String,
444 tag: String,
446 },
447 RawId {
449 window_id: String,
451 tag: String,
453 },
454}
455
456impl WindowOp {
457 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 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 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 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 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 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#[derive(Debug)]
873pub enum SystemOp {
874 AllowAutomaticTabbing(bool),
876}
877
878#[derive(Debug)]
880#[non_exhaustive]
881pub enum SystemQuery {
882 GetTheme {
884 tag: String,
886 },
887 GetInfo {
889 tag: String,
891 },
892}
893
894impl SystemOp {
895 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 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 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 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#[derive(Debug)]
953pub enum EffectRequest {
954 FileOpen(FileDialogOpts),
956 FileOpenMultiple(FileDialogOpts),
958 FileSave(FileDialogOpts),
960 DirectorySelect(FileDialogOpts),
962 DirectorySelectMultiple(FileDialogOpts),
964 ClipboardRead,
966 ClipboardWrite(String),
968 ClipboardReadHtml,
970 ClipboardWriteHtml {
972 html: String,
974 alt_text: Option<String>,
976 },
977 ClipboardClear,
979 ClipboardReadPrimary,
981 ClipboardWritePrimary(String),
983 Notification {
985 title: String,
987 body: String,
989 opts: NotificationOpts,
991 },
992}
993
994impl EffectRequest {
995 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
1015pub 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#[derive(Debug, Clone, PartialEq, Eq)]
1037pub enum EffectRequestValidationError {
1038 UnknownKind {
1040 kind: String,
1042 },
1043 InvalidPayload {
1045 kind: String,
1047 expected: &'static str,
1049 },
1050 MissingField {
1052 kind: String,
1054 field: &'static str,
1056 },
1057 InvalidFieldType {
1059 kind: String,
1061 field: &'static str,
1063 expected: &'static str,
1065 },
1066 InvalidFieldValue {
1068 kind: String,
1070 field: &'static str,
1072 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#[derive(Debug, Default)]
1108pub struct FileDialogOpts {
1109 pub title: Option<String>,
1111 pub directory: Option<String>,
1113 pub filters: Vec<(String, Vec<String>)>,
1115 pub default_name: Option<String>,
1117}
1118
1119impl FileDialogOpts {
1120 pub fn new() -> Self {
1122 Self::default()
1123 }
1124
1125 pub fn title(mut self, title: &str) -> Self {
1127 self.title = Some(title.to_string());
1128 self
1129 }
1130
1131 pub fn directory(mut self, dir: &str) -> Self {
1133 self.directory = Some(dir.to_string());
1134 self
1135 }
1136
1137 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 pub fn default_name(mut self, name: &str) -> Self {
1148 self.default_name = Some(name.to_string());
1149 self
1150 }
1151}
1152
1153#[derive(Debug, Default)]
1159pub struct NotificationOpts {
1160 pub icon: Option<String>,
1162 pub timeout: Option<Duration>,
1167 pub urgency: Option<NotificationUrgency>,
1169 pub sound: Option<String>,
1171}
1172
1173impl NotificationOpts {
1174 pub fn new() -> Self {
1176 Self::default()
1177 }
1178
1179 pub fn icon(mut self, icon: &str) -> Self {
1181 self.icon = Some(icon.to_string());
1182 self
1183 }
1184
1185 pub fn timeout(mut self, timeout: Duration) -> Self {
1187 self.timeout = Some(timeout);
1188 self
1189 }
1190
1191 pub fn urgency(mut self, urgency: NotificationUrgency) -> Self {
1193 self.urgency = Some(urgency);
1194 self
1195 }
1196
1197 pub fn sound(mut self, sound: &str) -> Self {
1199 self.sound = Some(sound.to_string());
1200 self
1201 }
1202}
1203
1204#[derive(Debug)]
1210#[non_exhaustive]
1211pub enum ImageOp {
1212 Create {
1214 handle: String,
1216 data: Vec<u8>,
1218 },
1219 CreateRaw {
1221 handle: String,
1223 width: u32,
1225 height: u32,
1227 pixels: Vec<u8>,
1229 },
1230 Update {
1232 handle: String,
1234 data: Vec<u8>,
1236 },
1237 UpdateRaw {
1239 handle: String,
1241 width: u32,
1243 height: u32,
1245 pixels: Vec<u8>,
1247 },
1248 Delete(String),
1250 List {
1252 tag: String,
1254 },
1255 Clear,
1257}
1258
1259#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1271pub struct WidgetCommand {
1272 pub id: String,
1274 pub family: String,
1276 #[serde(default)]
1278 pub value: Value,
1279}
1280
1281impl WidgetCommand {
1282 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 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
1304pub 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
1380pub 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
1446pub fn effect_request_from_wire(kind: &str, payload: &Value) -> Option<EffectRequest> {
1450 validate_effect_request_from_wire(kind, payload).ok()
1451}
1452
1453impl 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 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 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 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 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 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 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 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 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 #[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 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}