use std::fmt;
use std::time::Duration;
use serde_json::Map;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WindowMode {
Windowed,
Fullscreen,
}
impl fmt::Display for WindowMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Windowed => f.write_str("windowed"),
Self::Fullscreen => f.write_str("fullscreen"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WindowLevel {
Normal,
AlwaysOnTop,
AlwaysOnBottom,
}
impl fmt::Display for WindowLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Normal => f.write_str("normal"),
Self::AlwaysOnTop => f.write_str("always_on_top"),
Self::AlwaysOnBottom => f.write_str("always_on_bottom"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationUrgency {
Low,
Normal,
Critical,
}
impl fmt::Display for NotificationUrgency {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Low => f.write_str("low"),
Self::Normal => f.write_str("normal"),
Self::Critical => f.write_str("critical"),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RendererOp {
Command {
id: String,
family: String,
value: Value,
},
Commands(Vec<WidgetCommand>),
FocusNext,
FocusPrevious,
FocusNextWithin {
scope: String,
},
FocusPreviousWithin {
scope: String,
},
Window(WindowOp),
WindowQuery(WindowQuery),
SystemOp(SystemOp),
SystemQuery(SystemQuery),
Effect {
tag: String,
request: EffectRequest,
timeout: Option<Duration>,
},
Image(ImageOp),
Announce {
text: String,
politeness: crate::types::Live,
},
LoadFont {
family: String,
bytes: Vec<u8>,
},
Subscribe {
kind: String,
tag: String,
max_rate: Option<u32>,
window_id: Option<String>,
},
Unsubscribe {
kind: String,
tag: String,
},
TreeHash {
tag: String,
},
FindFocused {
tag: String,
},
AdvanceFrame {
timestamp: u64,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum WindowOp {
Open {
window_id: String,
settings: Value,
},
Update {
window_id: String,
settings: Value,
},
Close(String),
Resize {
window_id: String,
width: f32,
height: f32,
},
Move {
window_id: String,
x: f32,
y: f32,
},
Maximize {
window_id: String,
maximized: bool,
},
Minimize {
window_id: String,
minimized: bool,
},
SetMode {
window_id: String,
mode: WindowMode,
},
ToggleMaximize(String),
ToggleDecorations(String),
FocusWindow(String),
SetLevel {
window_id: String,
level: WindowLevel,
},
DragWindow(String),
DragResize {
window_id: String,
direction: String,
},
RequestAttention {
window_id: String,
urgency: Option<NotificationUrgency>,
},
Screenshot {
window_id: String,
tag: String,
},
SetResizable {
window_id: String,
resizable: bool,
},
SetMinSize {
window_id: String,
width: f32,
height: f32,
},
SetMaxSize {
window_id: String,
width: f32,
height: f32,
},
EnableMousePassthrough(String),
DisableMousePassthrough(String),
ShowSystemMenu(String),
SetIcon {
window_id: String,
data: Vec<u8>,
width: u32,
height: u32,
},
SetResizeIncrements {
window_id: String,
width: f32,
height: f32,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum WindowQuery {
GetSize {
window_id: String,
tag: String,
},
GetPosition {
window_id: String,
tag: String,
},
IsMaximized {
window_id: String,
tag: String,
},
IsMinimized {
window_id: String,
tag: String,
},
GetMode {
window_id: String,
tag: String,
},
GetScaleFactor {
window_id: String,
tag: String,
},
MonitorSize {
window_id: String,
tag: String,
},
RawId {
window_id: String,
tag: String,
},
}
impl WindowOp {
pub fn from_wire(op: &str, window_id: &str, payload: &Value) -> Option<Self> {
let wid = || window_id.to_string();
let f = |key: &str, default: f32| -> f32 {
payload
.get(key)
.and_then(|v| v.as_f64())
.map(|v| v as f32)
.unwrap_or(default)
};
let b = |key: &str, default: bool| -> bool {
payload
.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(default)
};
let s = |key: &str| -> String {
payload
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
};
match op {
"open" => Some(Self::Open {
window_id: wid(),
settings: payload.clone(),
}),
"update" => Some(Self::Update {
window_id: wid(),
settings: payload.clone(),
}),
"close" => Some(Self::Close(wid())),
"resize" => Some(Self::Resize {
window_id: wid(),
width: f("width", 800.0),
height: f("height", 600.0),
}),
"move" => Some(Self::Move {
window_id: wid(),
x: f("x", 0.0),
y: f("y", 0.0),
}),
"maximize" => Some(Self::Maximize {
window_id: wid(),
maximized: b("maximized", true),
}),
"minimize" => Some(Self::Minimize {
window_id: wid(),
minimized: b("minimized", true),
}),
"set_mode" => {
let mode = payload
.get("mode")
.and_then(|v| v.as_str())
.map(|s| match s {
"fullscreen" => WindowMode::Fullscreen,
_ => WindowMode::Windowed,
})
.unwrap_or(WindowMode::Windowed);
Some(Self::SetMode {
window_id: wid(),
mode,
})
}
"toggle_maximize" => Some(Self::ToggleMaximize(wid())),
"toggle_decorations" => Some(Self::ToggleDecorations(wid())),
"gain_focus" => Some(Self::FocusWindow(wid())),
"set_level" => {
let level = payload
.get("level")
.and_then(|v| v.as_str())
.map(|s| match s {
"always_on_top" => WindowLevel::AlwaysOnTop,
"always_on_bottom" => WindowLevel::AlwaysOnBottom,
_ => WindowLevel::Normal,
})
.unwrap_or(WindowLevel::Normal);
Some(Self::SetLevel {
window_id: wid(),
level,
})
}
"drag" => Some(Self::DragWindow(wid())),
"drag_resize" => Some(Self::DragResize {
window_id: wid(),
direction: s("direction"),
}),
"request_attention" => {
let urgency =
payload
.get("urgency")
.and_then(|v| v.as_str())
.and_then(|s| match s {
"low" => Some(NotificationUrgency::Low),
"normal" => Some(NotificationUrgency::Normal),
"critical" => Some(NotificationUrgency::Critical),
_ => None,
});
Some(Self::RequestAttention {
window_id: wid(),
urgency,
})
}
"screenshot" => Some(Self::Screenshot {
window_id: wid(),
tag: s("tag"),
}),
"set_resizable" => Some(Self::SetResizable {
window_id: wid(),
resizable: b("resizable", true),
}),
"set_min_size" => Some(Self::SetMinSize {
window_id: wid(),
width: f("width", 0.0),
height: f("height", 0.0),
}),
"set_max_size" => Some(Self::SetMaxSize {
window_id: wid(),
width: f("width", 0.0),
height: f("height", 0.0),
}),
"mouse_passthrough" => {
let enabled = b("enabled", true);
if enabled {
Some(Self::EnableMousePassthrough(wid()))
} else {
Some(Self::DisableMousePassthrough(wid()))
}
}
"show_system_menu" => Some(Self::ShowSystemMenu(wid())),
"set_icon" => {
use base64::Engine as _;
let b64 = payload.get("data").and_then(|v| v.as_str())?;
let data = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
let width = payload.get("width").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let height = payload.get("height").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
Some(Self::SetIcon {
window_id: wid(),
data,
width,
height,
})
}
"set_resize_increments" => Some(Self::SetResizeIncrements {
window_id: wid(),
width: f("width", 0.0),
height: f("height", 0.0),
}),
_ => None,
}
}
pub fn to_wire(&self) -> (&'static str, String, Value) {
use serde_json::json;
match self {
Self::Open {
window_id,
settings,
} => ("open", window_id.clone(), settings.clone()),
Self::Update {
window_id,
settings,
} => ("update", window_id.clone(), settings.clone()),
Self::Close(id) => ("close", id.clone(), Value::Null),
Self::Resize {
window_id,
width,
height,
} => (
"resize",
window_id.clone(),
json!({"width": width, "height": height}),
),
Self::Move { window_id, x, y } => ("move", window_id.clone(), json!({"x": x, "y": y})),
Self::Maximize {
window_id,
maximized,
} => (
"maximize",
window_id.clone(),
json!({"maximized": maximized}),
),
Self::Minimize {
window_id,
minimized,
} => (
"minimize",
window_id.clone(),
json!({"minimized": minimized}),
),
Self::SetMode { window_id, mode } => (
"set_mode",
window_id.clone(),
json!({"mode": mode.to_string()}),
),
Self::ToggleMaximize(id) => ("toggle_maximize", id.clone(), json!({})),
Self::ToggleDecorations(id) => ("toggle_decorations", id.clone(), json!({})),
Self::FocusWindow(id) => ("gain_focus", id.clone(), json!({})),
Self::SetLevel { window_id, level } => (
"set_level",
window_id.clone(),
json!({"level": level.to_string()}),
),
Self::DragWindow(id) => ("drag", id.clone(), json!({})),
Self::DragResize {
window_id,
direction,
} => (
"drag_resize",
window_id.clone(),
json!({"direction": direction}),
),
Self::RequestAttention { window_id, urgency } => {
let mut v = json!({});
if let Some(u) = urgency {
v["urgency"] = json!(u);
}
("request_attention", window_id.clone(), v)
}
Self::Screenshot { window_id, tag } => {
("screenshot", window_id.clone(), json!({"tag": tag}))
}
Self::SetResizable {
window_id,
resizable,
} => (
"set_resizable",
window_id.clone(),
json!({"resizable": resizable}),
),
Self::SetMinSize {
window_id,
width,
height,
} => (
"set_min_size",
window_id.clone(),
json!({"width": width, "height": height}),
),
Self::SetMaxSize {
window_id,
width,
height,
} => (
"set_max_size",
window_id.clone(),
json!({"width": width, "height": height}),
),
Self::EnableMousePassthrough(id) => {
("mouse_passthrough", id.clone(), json!({"enabled": true}))
}
Self::DisableMousePassthrough(id) => {
("mouse_passthrough", id.clone(), json!({"enabled": false}))
}
Self::ShowSystemMenu(id) => ("show_system_menu", id.clone(), json!({})),
Self::SetIcon {
window_id,
data,
width,
height,
} => {
use base64::Engine as _;
let b64 = base64::engine::general_purpose::STANDARD.encode(data);
(
"set_icon",
window_id.clone(),
json!({"data": b64, "width": width, "height": height}),
)
}
Self::SetResizeIncrements {
window_id,
width,
height,
} => (
"set_resize_increments",
window_id.clone(),
json!({"width": width, "height": height}),
),
}
}
pub fn window_id(&self) -> Option<&str> {
match self {
Self::Open { window_id, .. }
| Self::Update { window_id, .. }
| Self::Resize { window_id, .. }
| Self::Move { window_id, .. }
| Self::Maximize { window_id, .. }
| Self::Minimize { window_id, .. }
| Self::SetMode { window_id, .. }
| Self::SetLevel { window_id, .. }
| Self::DragResize { window_id, .. }
| Self::RequestAttention { window_id, .. }
| Self::Screenshot { window_id, .. }
| Self::SetResizable { window_id, .. }
| Self::SetMinSize { window_id, .. }
| Self::SetMaxSize { window_id, .. }
| Self::SetIcon { window_id, .. }
| Self::SetResizeIncrements { window_id, .. } => Some(window_id),
Self::Close(id)
| Self::ToggleMaximize(id)
| Self::ToggleDecorations(id)
| Self::FocusWindow(id)
| Self::DragWindow(id)
| Self::EnableMousePassthrough(id)
| Self::DisableMousePassthrough(id)
| Self::ShowSystemMenu(id) => Some(id),
}
}
}
impl WindowQuery {
pub fn from_wire(op: &str, window_id: &str, payload: &Value) -> Option<Self> {
let wid = window_id.to_string();
let tag = payload
.get("tag")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
match op {
"get_size" => Some(Self::GetSize {
window_id: wid,
tag,
}),
"get_position" => Some(Self::GetPosition {
window_id: wid,
tag,
}),
"is_maximized" => Some(Self::IsMaximized {
window_id: wid,
tag,
}),
"is_minimized" => Some(Self::IsMinimized {
window_id: wid,
tag,
}),
"get_mode" => Some(Self::GetMode {
window_id: wid,
tag,
}),
"get_scale_factor" => Some(Self::GetScaleFactor {
window_id: wid,
tag,
}),
"monitor_size" => Some(Self::MonitorSize {
window_id: wid,
tag,
}),
"raw_id" => Some(Self::RawId {
window_id: wid,
tag,
}),
_ => None,
}
}
pub fn to_wire(&self) -> (&'static str, String, Value) {
use serde_json::json;
match self {
Self::GetSize { window_id, tag } => {
("get_size", window_id.clone(), json!({"tag": tag}))
}
Self::GetPosition { window_id, tag } => {
("get_position", window_id.clone(), json!({"tag": tag}))
}
Self::IsMaximized { window_id, tag } => {
("is_maximized", window_id.clone(), json!({"tag": tag}))
}
Self::IsMinimized { window_id, tag } => {
("is_minimized", window_id.clone(), json!({"tag": tag}))
}
Self::GetMode { window_id, tag } => {
("get_mode", window_id.clone(), json!({"tag": tag}))
}
Self::GetScaleFactor { window_id, tag } => {
("get_scale_factor", window_id.clone(), json!({"tag": tag}))
}
Self::MonitorSize { window_id, tag } => {
("monitor_size", window_id.clone(), json!({"tag": tag}))
}
Self::RawId { window_id, tag } => ("raw_id", window_id.clone(), json!({"tag": tag})),
}
}
pub fn window_id(&self) -> &str {
match self {
Self::GetSize { window_id, .. }
| Self::GetPosition { window_id, .. }
| Self::IsMaximized { window_id, .. }
| Self::IsMinimized { window_id, .. }
| Self::GetMode { window_id, .. }
| Self::GetScaleFactor { window_id, .. }
| Self::MonitorSize { window_id, .. }
| Self::RawId { window_id, .. } => window_id,
}
}
}
#[derive(Debug)]
pub enum SystemOp {
AllowAutomaticTabbing(bool),
}
#[derive(Debug)]
#[non_exhaustive]
pub enum SystemQuery {
GetTheme {
tag: String,
},
GetInfo {
tag: String,
},
}
impl SystemOp {
pub fn from_wire(op: &str, payload: &Value) -> Option<Self> {
match op {
"allow_automatic_tabbing" => {
let enabled = payload
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(true);
Some(Self::AllowAutomaticTabbing(enabled))
}
_ => None,
}
}
pub fn to_wire(&self) -> (&'static str, Value) {
use serde_json::json;
match self {
Self::AllowAutomaticTabbing(enabled) => {
("allow_automatic_tabbing", json!({"enabled": enabled}))
}
}
}
}
impl SystemQuery {
pub fn from_wire(op: &str, payload: &Value) -> Option<Self> {
let tag = payload
.get("tag")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
match op {
"get_system_theme" => Some(Self::GetTheme { tag }),
"get_system_info" => Some(Self::GetInfo { tag }),
_ => None,
}
}
pub fn to_wire(&self) -> (&'static str, Value) {
use serde_json::json;
match self {
Self::GetTheme { tag } => ("get_system_theme", json!({"tag": tag})),
Self::GetInfo { tag } => ("get_system_info", json!({"tag": tag})),
}
}
}
#[derive(Debug)]
pub enum EffectRequest {
FileOpen(FileDialogOpts),
FileOpenMultiple(FileDialogOpts),
FileSave(FileDialogOpts),
DirectorySelect(FileDialogOpts),
DirectorySelectMultiple(FileDialogOpts),
ClipboardRead,
ClipboardWrite(String),
ClipboardReadHtml,
ClipboardWriteHtml {
html: String,
alt_text: Option<String>,
},
ClipboardClear,
ClipboardReadPrimary,
ClipboardWritePrimary(String),
Notification {
title: String,
body: String,
opts: NotificationOpts,
},
}
impl EffectRequest {
pub fn kind(&self) -> &'static str {
match self {
Self::FileOpen(_) => "file_open",
Self::FileOpenMultiple(_) => "file_open_multiple",
Self::FileSave(_) => "file_save",
Self::DirectorySelect(_) => "directory_select",
Self::DirectorySelectMultiple(_) => "directory_select_multiple",
Self::ClipboardRead => "clipboard_read",
Self::ClipboardWrite(_) => "clipboard_write",
Self::ClipboardReadHtml => "clipboard_read_html",
Self::ClipboardWriteHtml { .. } => "clipboard_write_html",
Self::ClipboardClear => "clipboard_clear",
Self::ClipboardReadPrimary => "clipboard_read_primary",
Self::ClipboardWritePrimary(_) => "clipboard_write_primary",
Self::Notification { .. } => "notification",
}
}
}
pub fn is_known_effect_kind(kind: &str) -> bool {
matches!(
kind,
"file_open"
| "file_open_multiple"
| "file_save"
| "directory_select"
| "directory_select_multiple"
| "clipboard_read"
| "clipboard_write"
| "clipboard_read_html"
| "clipboard_write_html"
| "clipboard_clear"
| "clipboard_read_primary"
| "clipboard_write_primary"
| "notification"
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EffectRequestValidationError {
UnknownKind {
kind: String,
},
InvalidPayload {
kind: String,
expected: &'static str,
},
MissingField {
kind: String,
field: &'static str,
},
InvalidFieldType {
kind: String,
field: &'static str,
expected: &'static str,
},
InvalidFieldValue {
kind: String,
field: &'static str,
detail: String,
},
}
impl fmt::Display for EffectRequestValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnknownKind { kind } => write!(f, "unknown effect kind: {kind}"),
Self::InvalidPayload { kind, expected } => {
write!(f, "invalid payload for {kind}: expected {expected}")
}
Self::MissingField { kind, field } => {
write!(f, "missing required field for {kind}: {field}")
}
Self::InvalidFieldType {
kind,
field,
expected,
} => write!(
f,
"invalid field type for {kind}.{field}: expected {expected}"
),
Self::InvalidFieldValue {
kind,
field,
detail,
} => write!(f, "invalid field value for {kind}.{field}: {detail}"),
}
}
}
impl std::error::Error for EffectRequestValidationError {}
#[derive(Debug, Default)]
pub struct FileDialogOpts {
pub title: Option<String>,
pub directory: Option<String>,
pub filters: Vec<(String, Vec<String>)>,
pub default_name: Option<String>,
}
impl FileDialogOpts {
pub fn new() -> Self {
Self::default()
}
pub fn title(mut self, title: &str) -> Self {
self.title = Some(title.to_string());
self
}
pub fn directory(mut self, dir: &str) -> Self {
self.directory = Some(dir.to_string());
self
}
pub fn filter(mut self, label: &str, extensions: &[&str]) -> Self {
self.filters.push((
label.to_string(),
extensions.iter().map(|e| e.to_string()).collect(),
));
self
}
pub fn default_name(mut self, name: &str) -> Self {
self.default_name = Some(name.to_string());
self
}
}
#[derive(Debug, Default)]
pub struct NotificationOpts {
pub icon: Option<String>,
pub timeout: Option<Duration>,
pub urgency: Option<NotificationUrgency>,
pub sound: Option<String>,
}
impl NotificationOpts {
pub fn new() -> Self {
Self::default()
}
pub fn icon(mut self, icon: &str) -> Self {
self.icon = Some(icon.to_string());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn urgency(mut self, urgency: NotificationUrgency) -> Self {
self.urgency = Some(urgency);
self
}
pub fn sound(mut self, sound: &str) -> Self {
self.sound = Some(sound.to_string());
self
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ImageOp {
Create {
handle: String,
data: Vec<u8>,
},
CreateRaw {
handle: String,
width: u32,
height: u32,
pixels: Vec<u8>,
},
Update {
handle: String,
data: Vec<u8>,
},
UpdateRaw {
handle: String,
width: u32,
height: u32,
pixels: Vec<u8>,
},
Delete(String),
List {
tag: String,
},
Clear,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct WidgetCommand {
pub id: String,
pub family: String,
#[serde(default)]
pub value: Value,
}
impl WidgetCommand {
pub fn new<C: crate::WidgetCommandEncode>(id: &str, cmd: C) -> Self {
let (family, value) = cmd.to_wire();
Self {
id: id.to_string(),
family: family.to_string(),
value: Value::from(value),
}
}
pub fn raw(id: &str, family: &str, value: impl Into<Value>) -> Self {
Self {
id: id.to_string(),
family: family.to_string(),
value: value.into(),
}
}
}
pub fn effect_request_to_wire(request: &EffectRequest) -> (&'static str, Value) {
use serde_json::json;
match request {
EffectRequest::FileOpen(opts) => ("file_open", file_dialog_opts_to_value(opts)),
EffectRequest::FileOpenMultiple(opts) => {
("file_open_multiple", file_dialog_opts_to_value(opts))
}
EffectRequest::FileSave(opts) => ("file_save", file_dialog_opts_to_value(opts)),
EffectRequest::DirectorySelect(opts) => {
("directory_select", file_dialog_opts_to_value(opts))
}
EffectRequest::DirectorySelectMultiple(opts) => {
("directory_select_multiple", file_dialog_opts_to_value(opts))
}
EffectRequest::ClipboardRead => ("clipboard_read", json!({})),
EffectRequest::ClipboardWrite(text) => ("clipboard_write", json!({"text": text})),
EffectRequest::ClipboardReadHtml => ("clipboard_read_html", json!({})),
EffectRequest::ClipboardWriteHtml { html, alt_text } => {
let mut payload = json!({"html": html});
if let Some(alt) = alt_text {
payload["alt_text"] = json!(alt);
}
("clipboard_write_html", payload)
}
EffectRequest::ClipboardClear => ("clipboard_clear", json!({})),
EffectRequest::ClipboardReadPrimary => ("clipboard_read_primary", json!({})),
EffectRequest::ClipboardWritePrimary(text) => {
("clipboard_write_primary", json!({"text": text}))
}
EffectRequest::Notification { title, body, opts } => {
let mut payload = json!({"title": title, "body": body});
if let Some(ref icon) = opts.icon {
payload["icon"] = json!(icon);
}
if let Some(ref timeout) = opts.timeout {
payload["timeout"] = json!(u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX));
}
if let Some(ref urgency) = opts.urgency {
payload["urgency"] = json!(urgency);
}
if let Some(ref sound) = opts.sound {
payload["sound"] = json!(sound);
}
("notification", payload)
}
}
}
fn file_dialog_opts_to_value(opts: &FileDialogOpts) -> Value {
use serde_json::json;
let mut payload = json!({});
if let Some(ref title) = opts.title {
payload["title"] = json!(title);
}
if let Some(ref dir) = opts.directory {
payload["directory"] = json!(dir);
}
if !opts.filters.is_empty() {
let filters: Vec<Value> = opts
.filters
.iter()
.map(|(label, exts)| json!([label, exts.join(";")]))
.collect();
payload["filters"] = json!(filters);
}
if let Some(ref name) = opts.default_name {
payload["default_name"] = json!(name);
}
payload
}
pub fn validate_effect_request_from_wire(
kind: &str,
payload: &Value,
) -> Result<EffectRequest, EffectRequestValidationError> {
if !is_known_effect_kind(kind) {
return Err(EffectRequestValidationError::UnknownKind {
kind: kind.to_string(),
});
}
let fields = payload_fields(kind, payload)?;
match kind {
"file_open" => Ok(EffectRequest::FileOpen(file_dialog_opts_from_fields(
kind, fields,
)?)),
"file_open_multiple" => Ok(EffectRequest::FileOpenMultiple(
file_dialog_opts_from_fields(kind, fields)?,
)),
"file_save" => Ok(EffectRequest::FileSave(file_dialog_opts_from_fields(
kind, fields,
)?)),
"directory_select" => Ok(EffectRequest::DirectorySelect(
file_dialog_opts_from_fields(kind, fields)?,
)),
"directory_select_multiple" => Ok(EffectRequest::DirectorySelectMultiple(
file_dialog_opts_from_fields(kind, fields)?,
)),
"clipboard_read" => Ok(EffectRequest::ClipboardRead),
"clipboard_write" => {
let text = required_string_field(kind, fields, "text")?;
Ok(EffectRequest::ClipboardWrite(text))
}
"clipboard_read_html" => Ok(EffectRequest::ClipboardReadHtml),
"clipboard_write_html" => {
let html = required_string_field(kind, fields, "html")?;
let alt_text = optional_string_field(kind, fields, "alt_text")?;
Ok(EffectRequest::ClipboardWriteHtml { html, alt_text })
}
"clipboard_clear" => Ok(EffectRequest::ClipboardClear),
"clipboard_read_primary" => Ok(EffectRequest::ClipboardReadPrimary),
"clipboard_write_primary" => {
let text = required_string_field(kind, fields, "text")?;
Ok(EffectRequest::ClipboardWritePrimary(text))
}
"notification" => {
let title = required_string_field(kind, fields, "title")?;
let body = required_string_field(kind, fields, "body")?;
let opts = NotificationOpts {
icon: optional_string_field(kind, fields, "icon")?,
timeout: optional_u64_field(kind, fields, "timeout")?.map(Duration::from_millis),
urgency: optional_urgency_field(kind, fields)?,
sound: optional_string_field(kind, fields, "sound")?,
};
Ok(EffectRequest::Notification { title, body, opts })
}
_ => unreachable!("effect kind was checked before parsing"),
}
}
pub fn effect_request_from_wire(kind: &str, payload: &Value) -> Option<EffectRequest> {
validate_effect_request_from_wire(kind, payload).ok()
}
impl crate::types::PlushieType for WindowLevel {
fn wire_decode(value: &Value) -> Option<Self> {
match value.as_str()? {
"normal" => Some(Self::Normal),
"always_on_top" => Some(Self::AlwaysOnTop),
"always_on_bottom" => Some(Self::AlwaysOnBottom),
_ => None,
}
}
fn wire_encode(&self) -> crate::protocol::PropValue {
crate::protocol::PropValue::Str(
match self {
Self::Normal => "normal",
Self::AlwaysOnTop => "always_on_top",
Self::AlwaysOnBottom => "always_on_bottom",
}
.into(),
)
}
fn type_name() -> &'static str {
"window_level"
}
}
fn payload_fields<'a>(
kind: &str,
payload: &'a Value,
) -> Result<&'a Map<String, Value>, EffectRequestValidationError> {
payload
.as_object()
.ok_or_else(|| EffectRequestValidationError::InvalidPayload {
kind: kind.to_string(),
expected: "object",
})
}
fn required_string_field(
kind: &str,
fields: &Map<String, Value>,
field: &'static str,
) -> Result<String, EffectRequestValidationError> {
match fields.get(field) {
Some(value) => value.as_str().map(ToString::to_string).ok_or_else(|| {
EffectRequestValidationError::InvalidFieldType {
kind: kind.to_string(),
field,
expected: "string",
}
}),
None => Err(EffectRequestValidationError::MissingField {
kind: kind.to_string(),
field,
}),
}
}
fn optional_string_field(
kind: &str,
fields: &Map<String, Value>,
field: &'static str,
) -> Result<Option<String>, EffectRequestValidationError> {
match fields.get(field) {
Some(value) => value.as_str().map(|s| Some(s.to_string())).ok_or_else(|| {
EffectRequestValidationError::InvalidFieldType {
kind: kind.to_string(),
field,
expected: "string",
}
}),
None => Ok(None),
}
}
fn optional_u64_field(
kind: &str,
fields: &Map<String, Value>,
field: &'static str,
) -> Result<Option<u64>, EffectRequestValidationError> {
match fields.get(field) {
Some(value) => {
value
.as_u64()
.map(Some)
.ok_or_else(|| EffectRequestValidationError::InvalidFieldType {
kind: kind.to_string(),
field,
expected: "unsigned integer",
})
}
None => Ok(None),
}
}
fn optional_urgency_field(
kind: &str,
fields: &Map<String, Value>,
) -> Result<Option<NotificationUrgency>, EffectRequestValidationError> {
let Some(value) = fields.get("urgency") else {
return Ok(None);
};
let Some(urgency) = value.as_str() else {
return Err(EffectRequestValidationError::InvalidFieldType {
kind: kind.to_string(),
field: "urgency",
expected: "string",
});
};
match urgency {
"low" => Ok(Some(NotificationUrgency::Low)),
"normal" => Ok(Some(NotificationUrgency::Normal)),
"critical" => Ok(Some(NotificationUrgency::Critical)),
_ => Err(EffectRequestValidationError::InvalidFieldValue {
kind: kind.to_string(),
field: "urgency",
detail: "expected low, normal, or critical".to_string(),
}),
}
}
fn file_dialog_opts_from_fields(
kind: &str,
fields: &Map<String, Value>,
) -> Result<FileDialogOpts, EffectRequestValidationError> {
Ok(FileDialogOpts {
title: optional_string_field(kind, fields, "title")?,
directory: optional_string_field(kind, fields, "directory")?,
default_name: optional_string_field(kind, fields, "default_name")?,
filters: file_dialog_filters_from_fields(kind, fields)?,
})
}
fn file_dialog_filters_from_fields(
kind: &str,
fields: &Map<String, Value>,
) -> Result<Vec<(String, Vec<String>)>, EffectRequestValidationError> {
let Some(value) = fields.get("filters") else {
return Ok(Vec::new());
};
let filters =
value
.as_array()
.ok_or_else(|| EffectRequestValidationError::InvalidFieldType {
kind: kind.to_string(),
field: "filters",
expected: "array",
})?;
let mut parsed = Vec::new();
for filter in filters {
let pair =
filter
.as_array()
.ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
kind: kind.to_string(),
field: "filters",
detail: "each filter must be [name, extensions]".to_string(),
})?;
if pair.len() < 2 {
return Err(EffectRequestValidationError::InvalidFieldValue {
kind: kind.to_string(),
field: "filters",
detail: "each filter must include a name and extensions".to_string(),
});
}
let name =
pair[0]
.as_str()
.ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
kind: kind.to_string(),
field: "filters",
detail: "filter name must be a string".to_string(),
})?;
let ext =
pair[1]
.as_str()
.ok_or_else(|| EffectRequestValidationError::InvalidFieldValue {
kind: kind.to_string(),
field: "filters",
detail: "filter extensions must be a string".to_string(),
})?;
let extensions: Vec<String> = ext
.split(';')
.map(|e| e.trim().trim_start_matches("*.").to_string())
.collect();
parsed.push((name.to_string(), extensions));
}
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn effect_parser_rejects_missing_required_field() {
let err = validate_effect_request_from_wire("clipboard_write", &json!({})).unwrap_err();
assert_eq!(
err,
EffectRequestValidationError::MissingField {
kind: "clipboard_write".to_string(),
field: "text",
}
);
}
#[test]
fn effect_parser_rejects_unknown_kind() {
let err = validate_effect_request_from_wire("not_real", &json!({})).unwrap_err();
assert_eq!(
err,
EffectRequestValidationError::UnknownKind {
kind: "not_real".to_string(),
}
);
}
#[test]
fn effect_parser_rejects_wrong_typed_required_field() {
let err =
validate_effect_request_from_wire("notification", &json!({"title": 1, "body": "hi"}))
.unwrap_err();
assert_eq!(
err,
EffectRequestValidationError::InvalidFieldType {
kind: "notification".to_string(),
field: "title",
expected: "string",
}
);
}
#[test]
fn effect_parser_rejects_wrong_typed_optional_field() {
let err = validate_effect_request_from_wire(
"clipboard_write_html",
&json!({"html": "<b>hi</b>", "alt_text": false}),
)
.unwrap_err();
assert_eq!(
err,
EffectRequestValidationError::InvalidFieldType {
kind: "clipboard_write_html".to_string(),
field: "alt_text",
expected: "string",
}
);
}
#[test]
fn effect_parser_rejects_invalid_file_dialog_filters() {
let err = validate_effect_request_from_wire(
"file_open",
&json!({"filters": [{"name": "Images", "extensions": "png"}]}),
)
.unwrap_err();
assert!(matches!(
err,
EffectRequestValidationError::InvalidFieldValue {
kind,
field: "filters",
..
} if kind == "file_open"
));
}
#[test]
fn effect_parser_parses_valid_required_fields() {
let request = validate_effect_request_from_wire(
"notification",
&json!({
"title": "Build done",
"body": "All checks passed",
"timeout": 1500,
"urgency": "normal",
}),
)
.unwrap();
match request {
EffectRequest::Notification { title, body, opts } => {
assert_eq!(title, "Build done");
assert_eq!(body, "All checks passed");
assert_eq!(opts.timeout, Some(Duration::from_millis(1500)));
assert_eq!(opts.urgency, Some(NotificationUrgency::Normal));
}
other => panic!("expected notification, got {other:?}"),
}
}
fn window_op_round_trip(op: WindowOp) {
let (op_str, wid, payload) = op.to_wire();
let parsed = WindowOp::from_wire(op_str, &wid, &payload)
.unwrap_or_else(|| panic!("WindowOp::from_wire returned None for op={op_str}"));
let (re_op_str, re_wid, re_payload) = parsed.to_wire();
assert_eq!(op_str, re_op_str, "op string drift");
assert_eq!(wid, re_wid, "window_id drift");
assert_eq!(payload, re_payload, "payload drift");
}
#[test]
fn window_op_open_round_trips() {
window_op_round_trip(WindowOp::Open {
window_id: "main".into(),
settings: json!({"title": "App", "size": [800, 600]}),
});
}
#[test]
fn window_op_update_round_trips() {
window_op_round_trip(WindowOp::Update {
window_id: "main".into(),
settings: json!({"title": "Renamed"}),
});
}
#[test]
fn window_op_close_round_trips() {
window_op_round_trip(WindowOp::Close("popup".into()));
}
#[test]
fn window_op_resize_round_trips() {
window_op_round_trip(WindowOp::Resize {
window_id: "main".into(),
width: 800.0,
height: 600.0,
});
}
#[test]
fn window_op_move_round_trips() {
window_op_round_trip(WindowOp::Move {
window_id: "main".into(),
x: 100.0,
y: 200.0,
});
}
#[test]
fn window_op_maximize_round_trips() {
window_op_round_trip(WindowOp::Maximize {
window_id: "main".into(),
maximized: true,
});
}
#[test]
fn window_op_minimize_round_trips() {
window_op_round_trip(WindowOp::Minimize {
window_id: "main".into(),
minimized: false,
});
}
#[test]
fn window_op_set_mode_round_trips() {
window_op_round_trip(WindowOp::SetMode {
window_id: "main".into(),
mode: WindowMode::Fullscreen,
});
window_op_round_trip(WindowOp::SetMode {
window_id: "main".into(),
mode: WindowMode::Windowed,
});
}
#[test]
fn window_op_unit_variants_round_trip() {
for op in [
WindowOp::ToggleMaximize("main".into()),
WindowOp::ToggleDecorations("main".into()),
WindowOp::FocusWindow("main".into()),
WindowOp::DragWindow("main".into()),
WindowOp::EnableMousePassthrough("main".into()),
WindowOp::DisableMousePassthrough("main".into()),
WindowOp::ShowSystemMenu("main".into()),
] {
window_op_round_trip(op);
}
}
#[test]
fn window_op_set_level_round_trips() {
for level in [
WindowLevel::Normal,
WindowLevel::AlwaysOnTop,
WindowLevel::AlwaysOnBottom,
] {
window_op_round_trip(WindowOp::SetLevel {
window_id: "main".into(),
level,
});
}
}
#[test]
fn window_op_drag_resize_round_trips() {
window_op_round_trip(WindowOp::DragResize {
window_id: "main".into(),
direction: "north_east".into(),
});
}
#[test]
fn window_op_request_attention_round_trips() {
for urgency in [
None,
Some(NotificationUrgency::Low),
Some(NotificationUrgency::Normal),
Some(NotificationUrgency::Critical),
] {
window_op_round_trip(WindowOp::RequestAttention {
window_id: "main".into(),
urgency,
});
}
}
#[test]
fn window_op_screenshot_round_trips() {
window_op_round_trip(WindowOp::Screenshot {
window_id: "main".into(),
tag: "snap".into(),
});
}
#[test]
fn window_op_resizable_min_max_round_trip() {
window_op_round_trip(WindowOp::SetResizable {
window_id: "main".into(),
resizable: true,
});
window_op_round_trip(WindowOp::SetMinSize {
window_id: "main".into(),
width: 320.0,
height: 240.0,
});
window_op_round_trip(WindowOp::SetMaxSize {
window_id: "main".into(),
width: 1920.0,
height: 1080.0,
});
window_op_round_trip(WindowOp::SetResizeIncrements {
window_id: "main".into(),
width: 8.0,
height: 8.0,
});
}
#[test]
fn window_op_set_icon_round_trips_with_base64() {
let bytes = vec![0xAA_u8, 0xBB, 0xCC, 0xDD];
window_op_round_trip(WindowOp::SetIcon {
window_id: "main".into(),
data: bytes,
width: 16,
height: 16,
});
}
#[test]
fn window_op_set_icon_invalid_base64_returns_none() {
let payload = json!({"data": "***not-base64***", "width": 16, "height": 16});
assert!(WindowOp::from_wire("set_icon", "main", &payload).is_none());
}
#[test]
fn window_op_unknown_op_returns_none() {
let payload = json!({});
assert!(WindowOp::from_wire("not_a_real_op", "main", &payload).is_none());
}
#[test]
fn window_op_resize_uses_payload_defaults_when_fields_missing() {
let parsed = WindowOp::from_wire("resize", "main", &json!({})).unwrap();
match parsed {
WindowOp::Resize { width, height, .. } => {
assert_eq!(width, 800.0);
assert_eq!(height, 600.0);
}
other => panic!("expected Resize, got {other:?}"),
}
}
#[test]
fn window_op_window_id_accessor_returns_target() {
assert_eq!(
WindowOp::Resize {
window_id: "main".into(),
width: 0.0,
height: 0.0,
}
.window_id(),
Some("main"),
);
assert_eq!(WindowOp::Close("popup".into()).window_id(), Some("popup"),);
}
fn window_query_round_trip(q: WindowQuery) {
let (op_str, wid, payload) = q.to_wire();
let parsed = WindowQuery::from_wire(op_str, &wid, &payload)
.unwrap_or_else(|| panic!("WindowQuery::from_wire returned None for op={op_str}"));
let (re_op_str, re_wid, re_payload) = parsed.to_wire();
assert_eq!(op_str, re_op_str);
assert_eq!(wid, re_wid);
assert_eq!(payload, re_payload);
}
#[test]
fn window_query_all_variants_round_trip() {
let make = |build: fn(String, String) -> WindowQuery| build("main".into(), "tag1".into());
for q in [
make(|window_id, tag| WindowQuery::GetSize { window_id, tag }),
make(|window_id, tag| WindowQuery::GetPosition { window_id, tag }),
make(|window_id, tag| WindowQuery::IsMaximized { window_id, tag }),
make(|window_id, tag| WindowQuery::IsMinimized { window_id, tag }),
make(|window_id, tag| WindowQuery::GetMode { window_id, tag }),
make(|window_id, tag| WindowQuery::GetScaleFactor { window_id, tag }),
make(|window_id, tag| WindowQuery::MonitorSize { window_id, tag }),
make(|window_id, tag| WindowQuery::RawId { window_id, tag }),
] {
window_query_round_trip(q);
}
}
#[test]
fn window_query_unknown_op_returns_none() {
assert!(WindowQuery::from_wire("not_a_query", "main", &json!({})).is_none());
}
#[test]
fn system_op_allow_automatic_tabbing_round_trips() {
let op = SystemOp::AllowAutomaticTabbing(true);
let (op_str, payload) = op.to_wire();
assert_eq!(op_str, "allow_automatic_tabbing");
assert_eq!(payload, json!({"enabled": true}));
match SystemOp::from_wire(op_str, &payload).unwrap() {
SystemOp::AllowAutomaticTabbing(enabled) => assert!(enabled),
}
}
#[test]
fn system_op_allow_automatic_tabbing_default_when_missing() {
match SystemOp::from_wire("allow_automatic_tabbing", &json!({})).unwrap() {
SystemOp::AllowAutomaticTabbing(enabled) => assert!(enabled),
}
}
#[test]
fn system_op_unknown_op_returns_none() {
assert!(SystemOp::from_wire("not_a_real_system_op", &json!({})).is_none());
}
#[test]
fn system_query_get_theme_round_trips() {
let q = SystemQuery::GetTheme { tag: "t1".into() };
let (op_str, payload) = q.to_wire();
assert_eq!(op_str, "get_system_theme");
assert_eq!(payload, json!({"tag": "t1"}));
match SystemQuery::from_wire(op_str, &payload).unwrap() {
SystemQuery::GetTheme { tag } => assert_eq!(tag, "t1"),
other => panic!("expected GetTheme, got {other:?}"),
}
}
#[test]
fn system_query_get_info_round_trips() {
let q = SystemQuery::GetInfo { tag: "info".into() };
let (op_str, payload) = q.to_wire();
assert_eq!(op_str, "get_system_info");
match SystemQuery::from_wire(op_str, &payload).unwrap() {
SystemQuery::GetInfo { tag } => assert_eq!(tag, "info"),
other => panic!("expected GetInfo, got {other:?}"),
}
}
#[test]
fn system_query_unknown_op_returns_none() {
assert!(SystemQuery::from_wire("not_a_query", &json!({})).is_none());
}
#[test]
fn effect_parser_round_trips_typed_requests() {
let requests = vec![
EffectRequest::FileOpen(
FileDialogOpts::new()
.title("Open")
.filter("Images", &["png"]),
),
EffectRequest::FileOpenMultiple(FileDialogOpts::new()),
EffectRequest::FileSave(FileDialogOpts::new().default_name("note.txt")),
EffectRequest::DirectorySelect(FileDialogOpts::new().directory("/tmp")),
EffectRequest::DirectorySelectMultiple(FileDialogOpts::new()),
EffectRequest::ClipboardRead,
EffectRequest::ClipboardWrite("hello".to_string()),
EffectRequest::ClipboardReadHtml,
EffectRequest::ClipboardWriteHtml {
html: "<b>hello</b>".to_string(),
alt_text: Some("hello".to_string()),
},
EffectRequest::ClipboardClear,
EffectRequest::ClipboardReadPrimary,
EffectRequest::ClipboardWritePrimary("hello".to_string()),
EffectRequest::Notification {
title: "Done".to_string(),
body: "Saved".to_string(),
opts: NotificationOpts::new()
.icon("plushie")
.timeout(Duration::from_secs(1))
.urgency(NotificationUrgency::Low)
.sound("ding"),
},
];
for request in requests {
let (kind, payload) = effect_request_to_wire(&request);
let parsed = validate_effect_request_from_wire(kind, &payload)
.unwrap_or_else(|err| panic!("{kind} failed to parse: {err}"));
assert_eq!(parsed.kind(), kind);
}
}
}