1use super::props::{Props, PropsDirty, UpdateEvents};
6use super::style::{Style, StyleDirty};
7
8impl Props {
9 pub fn all_styles(&self) -> impl Iterator<Item = &Style> {
15 [
16 &self.style,
17 &self.hover_style,
18 &self.press_style,
19 &self.focus_style,
20 ]
21 .into_iter()
22 .flatten()
23 }
24
25 pub fn split_events(mut self) -> (Props, UpdateEvents) {
29 let events = UpdateEvents {
30 value: self.value.take(),
31 selection_start: self.selection_start.take(),
32 selection_end: self.selection_end.take(),
33 scroll_top: self.scroll_top.take(),
34 scroll_left: self.scroll_left.take(),
35 draw: self.draw.take(),
36 };
37 (self, events)
38 }
39
40 pub fn merge_delta(
45 &mut self,
46 delta: Props,
47 unset: &[String],
48 style_unset: &[String],
49 ) -> (PropsDirty, UpdateEvents) {
50 let mut dirty = PropsDirty::default();
51 let (delta, events) = delta.split_events();
52
53 if let Some(style_delta) = &delta.style {
55 let groups = self
56 .style
57 .get_or_insert_default()
58 .overlay_delta(style_delta);
59 dirty.style.0 |= groups;
60 }
61 if delta.hover_style.is_some() {
62 self.hover_style = delta.hover_style;
63 dirty.hover_style = true;
64 }
65 if delta.press_style.is_some() {
66 self.press_style = delta.press_style;
67 dirty.press_style = true;
68 }
69 if delta.focus_style.is_some() {
70 self.focus_style = delta.focus_style;
71 dirty.focus_style = true;
72 }
73 if let Some(shape) = delta.shape
83 && self.shape.as_ref() != Some(&shape)
84 {
85 self.shape = Some(shape);
86 dirty.shape = true;
87 }
88 if let Some(view_box) = delta.view_box
89 && self.view_box != Some(view_box)
90 {
91 self.view_box = Some(view_box);
92 dirty.view_box = true;
93 }
94 macro_rules! merge_bool {
97 ($($f:ident => $flag:ident),* $(,)?) => {
98 $(
99 if delta.$f {
100 self.$f = true;
101 dirty.$flag = true;
102 }
103 )*
104 };
105 }
106 merge_bool!(
107 on_click => pointer,
108 on_pointer_down => pointer,
109 on_pointer_move => pointer,
110 on_pointer_up => pointer,
111 on_pointer_enter => pointer,
112 on_pointer_leave => pointer,
113 on_scroll => scroll_listener,
114 on_wheel => wheel,
115 on_change => editable_handlers,
116 on_select => editable_handlers,
117 on_focus => editable_handlers,
118 on_blur => editable_handlers,
119 flip_x => image,
120 flip_y => image,
121 );
122 if delta.multiline {
125 self.multiline = true;
126 }
127 if delta.autofocus {
128 self.autofocus = true;
129 }
130 if delta.on_resize {
133 self.on_resize = true;
134 }
135 macro_rules! merge_option {
136 ($($f:ident => $($flag:ident)?),* $(,)?) => {
137 $(
138 if delta.$f.is_some() {
139 self.$f = delta.$f;
140 $( dirty.$flag = true; )?
141 }
142 )*
143 };
144 }
145 merge_option!(
146 scroll_step => scroll_step,
147 anchor => anchor,
148 src => image,
149 tint => image,
150 image_mode => image,
151 source_rect => image,
152 atlas => image,
153 visual_box => image,
154 target => target,
155 aria_label => aria_label,
156 max_length => , );
158
159 for name in unset {
161 match name.as_str() {
162 "style" => {
163 self.style = None;
164 dirty.style = StyleDirty::ALL;
165 }
166 "hoverStyle" => {
167 self.hover_style = None;
168 dirty.hover_style = true;
169 }
170 "pressStyle" => {
171 self.press_style = None;
172 dirty.press_style = true;
173 }
174 "focusStyle" => {
175 self.focus_style = None;
176 dirty.focus_style = true;
177 }
178 "onClick" => {
179 self.on_click = false;
180 dirty.pointer = true;
181 }
182 "onPointerDown" => {
183 self.on_pointer_down = false;
184 dirty.pointer = true;
185 }
186 "onPointerMove" => {
187 self.on_pointer_move = false;
188 dirty.pointer = true;
189 }
190 "onPointerUp" => {
191 self.on_pointer_up = false;
192 dirty.pointer = true;
193 }
194 "onPointerEnter" => {
195 self.on_pointer_enter = false;
196 dirty.pointer = true;
197 }
198 "onPointerLeave" => {
199 self.on_pointer_leave = false;
200 dirty.pointer = true;
201 }
202 "onScroll" => {
203 self.on_scroll = false;
204 dirty.scroll_listener = true;
205 }
206 "onWheel" => {
207 self.on_wheel = false;
208 dirty.wheel = true;
209 }
210 "onChange" => {
211 self.on_change = false;
212 dirty.editable_handlers = true;
213 }
214 "onSelect" => {
215 self.on_select = false;
216 dirty.editable_handlers = true;
217 }
218 "onFocus" => {
219 self.on_focus = false;
220 dirty.editable_handlers = true;
221 }
222 "onBlur" => {
223 self.on_blur = false;
224 dirty.editable_handlers = true;
225 }
226 "flipX" => {
227 self.flip_x = false;
228 dirty.image = true;
229 }
230 "flipY" => {
231 self.flip_y = false;
232 dirty.image = true;
233 }
234 "multiline" => self.multiline = false,
235 "autofocus" => self.autofocus = false,
236 "onResize" => self.on_resize = false,
237 "scrollStep" => {
238 self.scroll_step = None;
239 dirty.scroll_step = true;
240 }
241 "anchor" => {
242 self.anchor = None;
243 dirty.anchor = true;
244 }
245 "src" => {
246 self.src = None;
247 dirty.image = true;
248 }
249 "tint" => {
250 self.tint = None;
251 dirty.image = true;
252 }
253 "imageMode" => {
254 self.image_mode = None;
255 dirty.image = true;
256 }
257 "sourceRect" => {
258 self.source_rect = None;
259 dirty.image = true;
260 }
261 "atlas" => {
262 self.atlas = None;
263 dirty.image = true;
264 }
265 "visualBox" => {
266 self.visual_box = None;
267 dirty.image = true;
268 }
269 "target" => {
270 self.target = None;
271 dirty.target = true;
272 }
273 "shape" => {
274 self.shape = None;
275 dirty.shape = true;
276 }
277 "viewBox" => {
278 self.view_box = None;
279 dirty.view_box = true;
280 }
281 "ariaLabel" => {
282 self.aria_label = None;
283 dirty.aria_label = true;
284 }
285 "maxLength" => self.max_length = None,
286 "value" | "selectionStart" | "selectionEnd" | "scrollTop" | "scrollLeft"
289 | "draw" => {
290 tracing::warn!(
291 target: "bevy_react",
292 "event-like prop {name:?} in unset; nothing to reset"
293 );
294 }
295 other => {
296 tracing::warn!(
297 target: "bevy_react",
298 "unknown prop {other:?} in unset; ignoring"
299 );
300 }
301 }
302 }
303
304 if !style_unset.is_empty() {
307 let style = self.style.get_or_insert_default();
308 for name in style_unset {
309 if let Some(groups) = style.unset_field(name) {
310 dirty.style.0 |= groups;
311 }
312 }
313 }
314
315 (dirty, events)
316 }
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::protocol::animatable::AnimatableField;
323 use crate::protocol::props::{Props, props_from_json as props};
324 use crate::protocol::style::style_groups;
325 use crate::protocol::units::Length;
326 use crate::svg::ViewBox;
327
328 #[test]
330 fn merge_delta_sets_and_preserves() {
331 let mut cached = props(serde_json::json!({
332 "style": { "backgroundColor": "red", "outline": { "color": "white" } },
333 "hoverStyle": { "backgroundColor": "blue" },
334 "onClick": true,
335 "src": "a.png",
336 }));
337 let (dirty, ev) = cached.merge_delta(
338 props(serde_json::json!({ "style": { "width": 100 } })),
339 &[],
340 &[],
341 );
342
343 let style = cached.style.as_ref().unwrap();
344 assert_eq!(style.width.static_val(), Some(Length::Px(100.0)));
345 assert_eq!(
346 style.background_color.static_ref().map(String::as_str),
347 Some("red")
348 );
349 assert!(style.outline.is_some(), "untouched style fields preserved");
350 assert!(cached.hover_style.is_some(), "untouched props preserved");
351 assert!(cached.on_click);
352 assert_eq!(cached.src.as_deref(), Some("a.png"));
353
354 assert!(dirty.style.intersects(style_groups::LAYOUT));
355 assert!(
356 !dirty
357 .style
358 .intersects(style_groups::BACKGROUND | style_groups::OUTLINE),
359 "untouched groups must stay clean"
360 );
361 assert!(!dirty.hover_style && !dirty.pointer && !dirty.image);
362 assert!(dirty.style.intersects(style_groups::TRANSITION));
364 assert!(ev.value.is_none() && ev.draw.is_none());
365 }
366
367 #[test]
370 fn merge_delta_unsets() {
371 let mut cached = props(serde_json::json!({
372 "style": { "backgroundColor": "red", "width": 50 },
373 "hoverStyle": { "backgroundColor": "blue" },
374 "onClick": true,
375 }));
376 let (dirty, _) = cached.merge_delta(
377 Props::default(),
378 &["hoverStyle".into(), "onClick".into()],
379 &["backgroundColor".into()],
380 );
381
382 let style = cached.style.as_ref().unwrap();
383 assert_eq!(style.background_color, None);
384 assert_eq!(
385 style.width.static_val(),
386 Some(Length::Px(50.0)),
387 "other style fields kept"
388 );
389 assert!(cached.hover_style.is_none());
390 assert!(!cached.on_click);
391 assert!(dirty.style.intersects(style_groups::BACKGROUND));
392 assert!(!dirty.style.intersects(style_groups::LAYOUT));
393 assert!(dirty.hover_style && dirty.pointer);
394 assert!(dirty.any_style_variant());
395 }
396
397 #[test]
403 fn merge_delta_bool_false_is_noop_off_rides_unset() {
404 let mut cached = props(serde_json::json!({ "flipX": true, "flipY": true }));
405
406 let (dirty, _) = cached.merge_delta(props(serde_json::json!({ "flipX": false })), &[], &[]);
408 assert!(cached.flip_x, "explicit false in a delta must not clear");
409 assert!(!dirty.image);
410
411 let (dirty, _) = cached.merge_delta(Props::default(), &["flipX".into()], &[]);
413 assert!(!cached.flip_x);
414 assert!(cached.flip_y, "sibling flag untouched");
415 assert!(dirty.image);
416 }
417
418 #[test]
420 fn merge_delta_unsets_style_wholesale() {
421 let mut cached = props(serde_json::json!({
422 "style": { "backgroundColor": "red", "width": 50 },
423 }));
424 let (dirty, _) = cached.merge_delta(Props::default(), &["style".into()], &[]);
425 assert!(cached.style.is_none());
426 assert_eq!(dirty.style, StyleDirty::ALL);
427 }
428
429 #[test]
431 fn merge_delta_events_not_cached() {
432 let mut cached = Props::default();
433 let (dirty, ev) = cached.merge_delta(
434 props(serde_json::json!({
435 "value": "hi", "selectionStart": 1, "selectionEnd": 3,
436 "scrollTop": 40.0, "scrollLeft": 2.0,
437 })),
438 &[],
439 &[],
440 );
441 assert_eq!(ev.value.as_deref(), Some("hi"));
442 assert_eq!((ev.selection_start, ev.selection_end), (Some(1), Some(3)));
443 assert_eq!((ev.scroll_top, ev.scroll_left), (Some(40.0), Some(2.0)));
444 assert!(cached.value.is_none() && cached.scroll_top.is_none());
445 assert!(cached.selection_start.is_none());
446 assert!(!dirty.style.any() && !dirty.image && !dirty.anchor);
448 }
449
450 #[test]
453 fn merge_delta_replaces_variants_atomically() {
454 let mut cached = props(serde_json::json!({
455 "hoverStyle": { "backgroundColor": "blue", "width": 10 },
456 }));
457 let (dirty, _) = cached.merge_delta(
458 props(serde_json::json!({ "hoverStyle": { "outline": { "color": "white" } } })),
459 &[],
460 &[],
461 );
462 let hover = cached.hover_style.as_ref().unwrap();
463 assert!(hover.outline.is_some());
464 assert_eq!(hover.background_color, None, "atomic replace, not a merge");
465 assert_eq!(hover.width, None);
466 assert!(dirty.hover_style);
467 }
468
469 #[test]
474 fn merge_delta_replaces_shape_atomically() {
475 let mut cached = props(serde_json::json!({ "shape": { "cx": 5, "r": 2 } }));
476 let (dirty, _) =
477 cached.merge_delta(props(serde_json::json!({ "shape": { "cx": 9 } })), &[], &[]);
478 let shape = cached.shape.as_ref().unwrap();
479 assert_eq!(shape.cx.static_val(), Some(9.0));
480 assert_eq!(shape.r, None, "atomic replace: the absent attr is removed");
481 assert!(dirty.shape);
482
483 let (dirty, _) =
485 cached.merge_delta(props(serde_json::json!({ "shape": { "cx": 9 } })), &[], &[]);
486 assert!(!dirty.shape, "an identical shape re-send must not dirty");
487
488 let (dirty, _) = cached.merge_delta(Props::default(), &["shape".into()], &[]);
489 assert!(cached.shape.is_none());
490 assert!(dirty.shape);
491 }
492
493 #[test]
497 fn merge_delta_view_box_wire_name_set_and_unset() {
498 let mut cached = Props::default();
499 let (dirty, _) = cached.merge_delta(
500 props(serde_json::json!({ "viewBox": "0 0 100 50" })),
501 &[],
502 &[],
503 );
504 assert_eq!(
505 cached.view_box,
506 Some(ViewBox {
507 min: bevy::math::Vec2::ZERO,
508 size: bevy::math::Vec2::new(100.0, 50.0),
509 }),
510 "the camelCase `viewBox` wire name must land in `view_box`"
511 );
512 assert!(dirty.view_box);
513
514 let (dirty, _) = cached.merge_delta(
515 props(serde_json::json!({ "viewBox": "0 0 100 50" })),
516 &[],
517 &[],
518 );
519 assert!(
520 !dirty.view_box,
521 "an identical viewBox re-send must not dirty"
522 );
523
524 let (dirty, _) = cached.merge_delta(Props::default(), &["viewBox".into()], &[]);
525 assert!(cached.view_box.is_none());
526 assert!(dirty.view_box);
527 }
528
529 #[test]
532 fn shape_and_view_box_are_retained_not_events() {
533 let p = props(serde_json::json!({
534 "shape": { "cx": 1 },
535 "viewBox": "0 0 10 10",
536 }));
537 let (retained, ev) = p.split_events();
538 assert!(retained.shape.is_some() && retained.view_box.is_some());
539 assert!(ev.value.is_none() && ev.draw.is_none());
540 }
541
542 #[test]
545 fn merge_delta_ignores_unknown_names() {
546 let mut cached = props(serde_json::json!({ "style": { "width": 10 } }));
547 let (dirty, _) = cached.merge_delta(
548 Props::default(),
549 &["nope".into(), "value".into()],
550 &["alsoNope".into()],
551 );
552 assert_eq!(
553 cached.style.as_ref().unwrap().width.static_val(),
554 Some(Length::Px(10.0))
555 );
556 assert!(!dirty.style.any());
557 }
558
559 #[test]
561 fn merge_delta_converges() {
562 let base = serde_json::json!({
563 "style": { "backgroundColor": "red", "width": 10 }, "onClick": true,
564 });
565 let mut two_steps = props(base.clone());
566 two_steps.merge_delta(
567 props(serde_json::json!({ "style": { "width": 20 } })),
568 &[],
569 &[],
570 );
571 two_steps.merge_delta(
572 props(serde_json::json!({ "style": { "height": 5 } })),
573 &[],
574 &["backgroundColor".into()],
575 );
576
577 let mut one_step = props(base);
578 one_step.merge_delta(
579 props(serde_json::json!({ "style": { "width": 20, "height": 5 } })),
580 &[],
581 &["backgroundColor".into()],
582 );
583
584 let a = two_steps.style.as_ref().unwrap();
585 let b = one_step.style.as_ref().unwrap();
586 assert_eq!(a.width, b.width);
587 assert_eq!(a.height, b.height);
588 assert_eq!(a.background_color, b.background_color);
589 assert!(two_steps.on_click && one_step.on_click);
590 }
591
592 #[test]
594 fn split_events_strips_event_fields() {
595 let full = props(serde_json::json!({
596 "style": { "width": 10 }, "onClick": true, "value": "v",
597 "selectionStart": 0, "selectionEnd": 1, "scrollTop": 5.0,
598 }));
599 let (state, ev) = full.split_events();
600 assert!(state.style.is_some() && state.on_click);
601 assert!(state.value.is_none() && state.selection_start.is_none());
602 assert!(state.scroll_top.is_none());
603 assert_eq!(ev.value.as_deref(), Some("v"));
604 assert_eq!(ev.scroll_top, Some(5.0));
605 }
606
607 #[test]
610 fn merge_delta_on_resize_flag() {
611 let mut cached = Props::default();
612 let (dirty, _) =
613 cached.merge_delta(props(serde_json::json!({ "onResize": true })), &[], &[]);
614 assert!(cached.on_resize);
615 assert!(!dirty.pointer && !dirty.scroll_listener);
616 cached.merge_delta(Props::default(), &["onResize".into()], &[]);
617 assert!(!cached.on_resize);
618 }
619
620 #[test]
623 fn merge_delta_wheel_flag() {
624 let mut cached = Props::default();
625 let (dirty, _) =
626 cached.merge_delta(props(serde_json::json!({ "onWheel": true })), &[], &[]);
627 assert!(cached.on_wheel);
628 assert!(dirty.wheel);
629 assert!(!dirty.pointer && !dirty.scroll_listener);
630
631 let (dirty, _) = cached.merge_delta(Props::default(), &["onWheel".into()], &[]);
632 assert!(!cached.on_wheel);
633 assert!(dirty.wheel);
634 }
635
636 #[test]
639 fn merge_delta_cursor_group() {
640 let mut cached = Props::default();
641 let (dirty, _) = cached.merge_delta(
642 props(serde_json::json!({ "style": { "cursor": "pointer" } })),
643 &[],
644 &[],
645 );
646 assert_eq!(
647 cached.style.as_ref().unwrap().cursor.as_deref(),
648 Some("pointer")
649 );
650 assert!(dirty.style.intersects(style_groups::CURSOR));
651 assert!(!dirty.style.intersects(style_groups::LAYOUT));
652
653 let (dirty, _) = cached.merge_delta(Props::default(), &[], &["cursor".into()]);
654 assert_eq!(cached.style.as_ref().unwrap().cursor, None);
655 assert!(dirty.style.intersects(style_groups::CURSOR));
656 }
657
658 #[test]
661 fn merge_delta_background_image_group() {
662 let mut cached = Props::default();
663 let (dirty, _) = cached.merge_delta(
664 props(serde_json::json!({
665 "style": { "backgroundImage": { "src": "bg.png", "mode": "repeat" } }
666 })),
667 &[],
668 &[],
669 );
670 assert!(cached.style.as_ref().unwrap().background_image.is_some());
671 assert!(dirty.style.intersects(style_groups::BG_IMAGE));
672 assert!(!dirty.style.intersects(style_groups::LAYOUT));
673
674 let (dirty, _) = cached.merge_delta(Props::default(), &[], &["backgroundImage".into()]);
675 assert!(cached.style.as_ref().unwrap().background_image.is_none());
676 assert!(dirty.style.intersects(style_groups::BG_IMAGE));
677 }
678}