use std::fmt;
use serde::Deserialize;
use serde::de::{self, Deserializer, Visitor};
use crate::canvas::DrawCmd;
use super::NodeId;
use super::props::Props;
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "op", rename_all = "camelCase")]
pub enum Op {
Reset,
Create {
id: NodeId,
kind: String,
#[serde(default)]
props: Box<Props>,
#[serde(default)]
text: Option<String>,
},
CreateText { id: NodeId, text: String },
CreateTextSpan { id: NodeId, text: String },
Append { parent: NodeId, child: NodeId },
Insert {
parent: NodeId,
child: NodeId,
before: NodeId,
},
Remove { parent: NodeId, child: NodeId },
Update {
id: NodeId,
#[serde(default)]
props: Box<Props>,
#[serde(default)]
unset: Vec<String>,
#[serde(default, rename = "styleUnset")]
style_unset: Vec<String>,
},
UpdateText { id: NodeId, text: String },
Draw { id: NodeId, cmds: Vec<DrawCmd> },
}
pub struct OpBatch(pub Vec<Op>);
fn op_target_id(op: &Op) -> Option<NodeId> {
match op {
Op::Create { id, .. }
| Op::CreateText { id, .. }
| Op::CreateTextSpan { id, .. }
| Op::Update { id, .. }
| Op::UpdateText { id, .. }
| Op::Draw { id, .. } => Some(*id),
Op::Reset | Op::Append { .. } | Op::Insert { .. } | Op::Remove { .. } => None,
}
}
impl<'de> Deserialize<'de> for OpBatch {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BatchVisitor;
impl<'de> Visitor<'de> for BatchVisitor {
type Value = Vec<Op>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("an array of reconciler ops")
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<Op>, A::Error> {
crate::diag::decode_batch_start();
let mut ops = Vec::with_capacity(seq.size_hint().unwrap_or(0));
loop {
let mark = crate::diag::decode_watermark();
let Some(op) = seq.next_element::<Op>()? else {
break;
};
crate::diag::decode_attribute_since(mark, op_target_id(&op));
ops.push(op);
}
Ok(ops)
}
}
d.deserialize_seq(BatchVisitor).map(OpBatch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::style::Style;
#[test]
fn op_stays_narrow() {
let size = std::mem::size_of::<Op>();
assert!(
size <= 128,
"Op grew to {size} bytes — box the payload of the variant that widened it \
(every op in a batch pays this width)"
);
}
#[cfg(all(feature = "devtools", debug_assertions))]
#[test]
fn op_batch_attributes_decode_warnings() {
crate::diag::decode_report("length", "stale", "stale entry");
let json = r#"[
{"op":"update","id":7,"props":{"style":{"width":"aa16"}}},
{"op":"append","parent":0,"child":7},
{"op":"update","id":9,"props":{"style":{"display":"flexx","padding":"1px bogus"}}}
]"#;
let batch: OpBatch = serde_json::from_str(json).expect("batch decodes");
assert_eq!(batch.0.len(), 3, "fallbacks must not drop ops");
let warns = crate::diag::take_decode_warnings();
let brief: Vec<_> = warns
.iter()
.map(|w| (w.node, w.kind, w.value.as_str()))
.collect();
assert_eq!(
brief,
vec![
(Some(7), "length", "aa16"),
(Some(9), "display", "flexx"),
(Some(9), "rect", "bogus"),
],
);
assert!(warns.iter().all(|w| !w.message.is_empty()));
assert!(
crate::diag::take_decode_warnings().is_empty(),
"drain empties the sink"
);
}
#[test]
fn deserializes_editable_text_create() {
let json = r#"{"op":"create","id":7,"kind":"editableText","props":{
"value":"hi","maxLength":40,"multiline":true,"onChange":true,
"autofocus":true,"selectionStart":0,"selectionEnd":2,
"ariaLabel":"Name","onSelect":true,"onFocus":true,"onBlur":true,
"focusStyle":{"borderColor":"white"}}}"#;
match serde_json::from_str::<Op>(json).expect("valid op") {
Op::Create {
id, kind, props, ..
} => {
assert_eq!(id, 7);
assert_eq!(kind, "editableText");
assert_eq!(props.value.as_deref(), Some("hi"));
assert_eq!(props.max_length, Some(40));
assert!(props.multiline);
assert!(props.on_change);
assert!(props.autofocus);
assert_eq!(props.selection_start, Some(0));
assert_eq!(props.selection_end, Some(2));
assert_eq!(props.aria_label.as_deref(), Some("Name"));
assert!(props.on_select);
assert!(props.on_focus);
assert!(props.on_blur);
assert!(props.focus_style.is_some());
}
other => panic!("expected create, got {other:?}"),
}
}
#[test]
fn deserializes_update_delta_form() {
let minimal: Op = serde_json::from_str(r#"{"op":"update","id":3,"props":{}}"#).unwrap();
match minimal {
Op::Update {
unset, style_unset, ..
} => {
assert!(unset.is_empty() && style_unset.is_empty());
}
other => panic!("expected update, got {other:?}"),
}
let full: Op = serde_json::from_str(
r#"{"op":"update","id":3,"props":{"style":{"width":1}},
"unset":["onClick"],"styleUnset":["backgroundColor"]}"#,
)
.unwrap();
match full {
Op::Update {
unset, style_unset, ..
} => {
assert_eq!(unset, vec!["onClick"]);
assert_eq!(style_unset, vec!["backgroundColor"]);
}
other => panic!("expected update, got {other:?}"),
}
}
#[test]
fn deserializes_draw_op() {
let op: Op = serde_json::from_str(
r##"{"op":"draw","id":7,"cmds":[
{"cmd":"clear"},
{"cmd":"clearRect","x":1.0,"y":2.0,"w":3.0,"h":4.0},
{"cmd":"fillStyle","color":"#f00"}
]}"##,
)
.unwrap();
match op {
Op::Draw { id, cmds } => {
assert_eq!(id, 7);
assert_eq!(cmds.len(), 3);
assert_eq!(cmds[0], DrawCmd::Clear);
assert_eq!(
cmds[1],
DrawCmd::ClearRect {
x: 1.0,
y: 2.0,
w: 3.0,
h: 4.0
}
);
assert_eq!(
cmds[2],
DrawCmd::FillStyle {
color: "#f00".into()
}
);
}
other => panic!("expected draw, got {other:?}"),
}
}
#[test]
fn deserializes_cursor_name() {
let s: Style = serde_json::from_str(r#"{ "cursor": "pointer" }"#).expect("cursor decodes");
assert_eq!(s.cursor.as_deref(), Some("pointer"));
let s: Style =
serde_json::from_str(r#"{ "cursor": "hand" }"#).expect("custom name decodes");
assert_eq!(s.cursor.as_deref(), Some("hand"));
}
}