1#![allow(deprecated)]
2pub mod box_builder;
3pub mod intrinsic;
4pub mod legacy_dispatch;
5
6pub mod arrow;
7pub mod audio_spectrum;
8pub mod avatar;
9pub mod avatar_group;
10pub mod badge;
11pub mod callout;
12pub mod caption;
13pub mod chart;
14pub mod comparison;
15pub mod connector;
16pub mod container;
17pub mod countdown;
18pub mod counter;
19pub mod cursor;
20pub mod divider;
21pub mod dot_map;
22pub mod emitter;
23pub mod gauge;
24pub mod gif;
25pub mod gradient_text;
26pub mod heatmap;
27pub mod icon;
28pub mod image;
29pub mod kbd;
30pub mod line;
31pub mod list;
32pub mod lottie;
33pub mod marquee;
34pub mod mockup;
35pub mod number_wheel;
36pub mod particle;
37pub mod pill_nav;
38pub mod pointer;
39pub mod progress;
40pub mod qrcode;
41pub mod rating;
42pub mod rich_text;
43pub mod shape;
44pub mod skeleton;
45pub mod slider;
46pub mod sparkline;
47pub mod stat;
48pub mod stepper;
49pub mod success_check;
50pub mod svg;
51pub mod switch;
52pub mod table;
53pub mod tag_cloud;
54pub mod text;
55pub mod timeline;
56pub mod tooltip;
57pub mod treemap;
58pub mod video;
59pub mod waveform;
60pub mod world_bitmap;
61
62use schemars::JsonSchema;
63use serde::{Deserialize, Serialize};
64
65use rustmotion_core::css::CssStyle;
66use rustmotion_core::traits::{Animatable, Painter, Styled, StyledMut, Timed};
67
68pub use arrow::Arrow;
69pub use audio_spectrum::AudioSpectrum;
70pub use avatar::Avatar;
71pub use avatar_group::AvatarGroup;
72pub use badge::Badge;
73pub use callout::Callout;
74pub use caption::Caption;
75pub use chart::Chart;
76pub use comparison::Comparison;
77pub use connector::Connector;
78pub use container::ContainerComponent;
79pub use countdown::Countdown;
80pub use counter::Counter;
81pub use cursor::Cursor;
82pub use divider::Divider;
83pub use dot_map::DotMap;
84pub use emitter::Emitter;
85pub use gauge::Gauge;
86pub use gif::Gif;
87pub use gradient_text::GradientText;
88pub use heatmap::Heatmap;
89pub use icon::Icon;
90pub use image::Image;
91pub use kbd::Kbd;
92pub use line::Line;
93pub use list::List;
94pub use lottie::Lottie;
95pub use marquee::Marquee;
96pub use mockup::Mockup;
97pub use number_wheel::NumberWheel;
98pub use particle::Particle;
99pub use pill_nav::PillNav;
100pub use pointer::Pointer;
101pub use progress::Progress;
102pub use qrcode::QrCode;
103pub use rating::Rating;
104pub use rich_text::RichText;
105pub use shape::Shape;
106pub use skeleton::Skeleton;
107pub use slider::Slider;
108pub use sparkline::Sparkline;
109pub use stat::Stat;
110pub use stepper::Stepper;
111pub use success_check::SuccessCheck;
112pub use svg::Svg;
113pub use switch::Switch;
114pub use table::Table;
115pub use tag_cloud::TagCloud;
116pub use text::Text;
117pub use timeline::Timeline;
118pub use tooltip::Tooltip;
119pub use treemap::Treemap;
120pub use video::Video;
121pub use waveform::Waveform;
122
123pub fn is_recognized_position_name(s: &str) -> bool {
124 s == "absolute"
125}
126
127#[derive(Debug, Clone, Serialize, JsonSchema)]
128#[serde(untagged)]
129pub enum PositionMode {
130 Absolute { x: f32, y: f32 },
131 Named(String),
132}
133
134impl<'de> Deserialize<'de> for PositionMode {
135 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
136 where
137 D: serde::Deserializer<'de>,
138 {
139 #[derive(Deserialize)]
140 #[serde(untagged)]
141 enum Raw {
142 Absolute { x: f32, y: f32 },
143 Named(String),
144 }
145 Ok(match Raw::deserialize(deserializer)? {
146 Raw::Absolute { x, y } => PositionMode::Absolute { x, y },
147 Raw::Named(s) => {
148 if !is_recognized_position_name(&s) && warn_once_for(&s) {
149 eprintln!(
150 "Warning: position: \"{s}\" is not \"absolute\" — this component-level \
151 `position` shorthand only honours the literal \"absolute\" (paired with \
152 `x`/`y`); any other value, including CSS-legitimate ones like \
153 \"relative\"/\"static\", is accepted but silently drops `x`/`y` instead \
154 of positioning the element (it still removes the component from flex \
155 flow). Use `style.position` for real CSS relative/static semantics."
156 );
157 }
158 PositionMode::Named(s)
159 }
160 })
161 }
162}
163
164pub(crate) fn warn_once_for(value: &str) -> bool {
165 use std::collections::HashSet;
166 use std::sync::{Mutex, OnceLock};
167 static SEEN: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
168 SEEN.get_or_init(Default::default)
169 .lock()
170 .map(|mut seen| seen.insert(value.to_owned()))
171 .unwrap_or(false)
172}
173
174impl Default for PositionMode {
175 fn default() -> Self {
176 Self::Absolute { x: 0.0, y: 0.0 }
177 }
178}
179
180#[cfg(test)]
181mod position_mode_tests {
182 use super::*;
183
184 #[test]
185 fn absolute_is_recognized() {
186 assert!(is_recognized_position_name("absolute"));
187 }
188
189 #[test]
190 fn relative_and_static_and_typos_are_not_recognized() {
191 for s in ["relative", "static", "fixed", "sticky", "Absolute", "abs"] {
192 assert!(
193 !is_recognized_position_name(s),
194 "'{s}' must not be treated as the recognised \"absolute\" value"
195 );
196 }
197 }
198
199 #[test]
200 fn absolute_object_form_still_carries_x_y() {
201 let json =
202 r#"{ "position": { "x": 10.0, "y": 20.0 }, "type": "shape", "shape": "circle" }"#;
203 let child: ChildComponent = serde_json::from_str(json).unwrap();
204 assert_eq!(child.absolute_position(), Some((10.0, 20.0)));
205 }
206
207 #[test]
208 fn absolute_string_form_with_sibling_x_y_still_carries_them() {
209 let json =
210 r#"{ "position": "absolute", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#;
211 let child: ChildComponent = serde_json::from_str(json).unwrap();
212 assert_eq!(child.absolute_position(), Some((5.0, 7.0)));
213 }
214
215 #[test]
216 fn relative_still_parses_but_drops_x_y_and_the_helper_flags_it() {
217 let json =
218 r#"{ "position": "relative", "x": 5.0, "y": 7.0, "type": "shape", "shape": "circle" }"#;
219 let child: ChildComponent = serde_json::from_str(json).unwrap();
220 assert!(
221 !is_recognized_position_name("relative"),
222 "this is exactly the case the warning fires for"
223 );
224 assert_eq!(
225 child.absolute_position(),
226 None,
227 "x/y are indeed dropped for a non-\"absolute\" position — this is the silent \
228 behaviour being made loud, not a new regression"
229 );
230 assert!(!child.is_flow());
231 }
232
233 #[test]
234 fn the_warning_fires_once_per_distinct_value_not_once_per_frame() {
235 let value = "position-value-used-only-by-this-test";
236 assert!(warn_once_for(value), "first sighting must warn");
237 for _ in 0..1000 {
238 assert!(
239 !warn_once_for(value),
240 "re-parsing the same value must stay silent"
241 );
242 }
243 assert!(
244 warn_once_for("a-different-position-value-for-this-test"),
245 "a different bad value must still get its own warning"
246 );
247 }
248
249 #[test]
250 fn no_position_set_is_a_normal_flow_child() {
251 let json = r#"{ "type": "shape", "shape": "circle" }"#;
252 let child: ChildComponent = serde_json::from_str(json).unwrap();
253 assert!(child.is_flow());
254 assert_eq!(child.absolute_position(), None);
255 }
256}
257
258#[derive(Debug, Serialize, Deserialize, JsonSchema)]
259pub struct ChildComponent {
260 #[serde(flatten)]
261 pub component: Component,
262 #[serde(default)]
263 pub position: Option<PositionMode>,
264 #[serde(default)]
265 pub x: Option<f32>,
266 #[serde(default)]
267 pub y: Option<f32>,
268 #[serde(default, rename = "z-index")]
269 pub z_index: Option<i32>,
270 #[serde(default)]
279 pub id: Option<String>,
280 #[serde(default)]
291 pub bleed: bool,
292}
293
294impl ChildComponent {
295 pub fn is_flow(&self) -> bool {
296 self.position.is_none()
297 }
298
299 pub fn is_decorative(&self) -> bool {
300 matches!(
301 self.component,
302 Component::Particle(_) | Component::Emitter(_)
303 )
304 }
305
306 pub fn absolute_position(&self) -> Option<(f32, f32)> {
307 match &self.position {
308 Some(PositionMode::Absolute { x, y }) => Some((*x, *y)),
309 Some(PositionMode::Named(s)) if s == "absolute" => {
310 Some((self.x.unwrap_or(0.0), self.y.unwrap_or(0.0)))
311 }
312 _ => None,
313 }
314 }
315}
316
317#[derive(Debug, Serialize, Deserialize, JsonSchema)]
318#[serde(tag = "type", rename_all = "snake_case")]
319pub enum Component {
320 AudioSpectrum(AudioSpectrum),
321 Text(Text),
322 Shape(Shape),
323 Image(Image),
324 Icon(Icon),
325 Svg(Svg),
326 Video(Video),
327 Gif(Gif),
328 Counter(Counter),
329 Cursor(Cursor),
330 Caption(Caption),
331 Connector(Connector),
332 Avatar(Avatar),
333 AvatarGroup(AvatarGroup),
334 Arrow(Arrow),
335 Badge(Badge),
336 Callout(Callout),
337 Chart(Chart),
338 Comparison(Comparison),
339 Countdown(Countdown),
340 Divider(Divider),
341 DotMap(DotMap),
342 Emitter(Emitter),
343 Gauge(Gauge),
344 GradientText(GradientText),
345 Heatmap(Heatmap),
346 Kbd(Kbd),
347 Line(Line),
348 List(List),
349 Lottie(Lottie),
350 Marquee(Marquee),
351 Mockup(Mockup),
352 Particle(Particle),
353 PillNav(PillNav),
354 #[serde(alias = "progress_bar")]
355 Progress(Progress),
356 QrCode(QrCode),
357 NumberWheel(NumberWheel),
358 SuccessCheck(SuccessCheck),
359 Pointer(Pointer),
360 Rating(Rating),
361 Skeleton(Skeleton),
362 Slider(Slider),
363 Sparkline(Sparkline),
364 Stat(Stat),
365 Stepper(Stepper),
366 Switch(Switch),
367 RichText(RichText),
368 Table(Table),
369 TagCloud(TagCloud),
370 Timeline(Timeline),
371 Tooltip(Tooltip),
372 Treemap(Treemap),
373 #[serde(
374 rename = "div",
375 alias = "container",
376 alias = "card",
377 alias = "flex",
378 alias = "grid",
379 alias = "positioned"
380 )]
381 Container(ContainerComponent),
382 Waveform(Waveform),
383}
384
385impl Component {
386 pub fn as_animatable(&self) -> Option<&dyn Animatable> {
387 match self {
388 Component::AudioSpectrum(c) => Some(c),
389 Component::Waveform(c) => Some(c),
390 Component::Text(c) => Some(c),
391 Component::Shape(c) => Some(c),
392 Component::Image(c) => Some(c),
393 Component::Icon(c) => Some(c),
394 Component::Svg(c) => Some(c),
395 Component::Video(c) => Some(c),
396 Component::Gif(c) => Some(c),
397 Component::Counter(c) => Some(c),
398 Component::Cursor(c) => Some(c),
399 Component::Caption(c) => Some(c),
400 Component::Avatar(c) => Some(c),
401 Component::AvatarGroup(c) => Some(c),
402 Component::Arrow(c) => Some(c),
403 Component::Connector(c) => Some(c),
404 Component::Badge(c) => Some(c),
405 Component::Callout(c) => Some(c),
406 Component::Chart(c) => Some(c),
407 Component::Comparison(c) => Some(c),
408 Component::Countdown(c) => Some(c),
409 Component::Divider(c) => Some(c),
410 Component::DotMap(c) => Some(c),
411 Component::Emitter(c) => Some(c),
412 Component::Gauge(c) => Some(c),
413 Component::GradientText(c) => Some(c),
414 Component::Heatmap(c) => Some(c),
415 Component::Kbd(c) => Some(c),
416 Component::Line(c) => Some(c),
417 Component::List(c) => Some(c),
418 Component::Lottie(c) => Some(c),
419 Component::Marquee(c) => Some(c),
420 Component::Mockup(c) => Some(c),
421 Component::Particle(c) => Some(c),
422 Component::PillNav(c) => Some(c),
423 Component::Progress(c) => Some(c),
424 Component::QrCode(c) => Some(c),
425 Component::NumberWheel(c) => Some(c),
426 Component::SuccessCheck(c) => Some(c),
427 Component::Pointer(c) => Some(c),
428 Component::Rating(c) => Some(c),
429 Component::Skeleton(c) => Some(c),
430 Component::Slider(c) => Some(c),
431 Component::Sparkline(c) => Some(c),
432 Component::Stat(c) => Some(c),
433 Component::Stepper(c) => Some(c),
434 Component::Switch(c) => Some(c),
435 Component::RichText(c) => Some(c),
436 Component::Table(c) => Some(c),
437 Component::TagCloud(c) => Some(c),
438 Component::Timeline(c) => Some(c),
439 Component::Tooltip(c) => Some(c),
440 Component::Treemap(c) => Some(c),
441 Component::Container(c) => Some(c),
442 }
443 }
444
445 pub fn as_timed(&self) -> Option<&dyn Timed> {
446 match self {
447 Component::AudioSpectrum(c) => Some(c),
448 Component::Waveform(c) => Some(c),
449 Component::Text(c) => Some(c),
450 Component::Shape(c) => Some(c),
451 Component::Image(c) => Some(c),
452 Component::Icon(c) => Some(c),
453 Component::Svg(c) => Some(c),
454 Component::Video(c) => Some(c),
455 Component::Gif(c) => Some(c),
456 Component::Counter(c) => Some(c),
457 Component::Cursor(c) => Some(c),
458 Component::Avatar(c) => Some(c),
459 Component::AvatarGroup(c) => Some(c),
460 Component::Arrow(c) => Some(c),
461 Component::Connector(c) => Some(c),
462 Component::Badge(c) => Some(c),
463 Component::Callout(c) => Some(c),
464 Component::Chart(c) => Some(c),
465 Component::Comparison(c) => Some(c),
466 Component::Countdown(c) => Some(c),
467 Component::Divider(c) => Some(c),
468 Component::DotMap(c) => Some(c),
469 Component::Emitter(c) => Some(c),
470 Component::Gauge(c) => Some(c),
471 Component::GradientText(c) => Some(c),
472 Component::Heatmap(c) => Some(c),
473 Component::Kbd(c) => Some(c),
474 Component::Line(c) => Some(c),
475 Component::List(c) => Some(c),
476 Component::Lottie(c) => Some(c),
477 Component::Marquee(c) => Some(c),
478 Component::Mockup(c) => Some(c),
479 Component::Particle(c) => Some(c),
480 Component::PillNav(c) => Some(c),
481 Component::Progress(c) => Some(c),
482 Component::QrCode(c) => Some(c),
483 Component::NumberWheel(c) => Some(c),
484 Component::SuccessCheck(c) => Some(c),
485 Component::Pointer(c) => Some(c),
486 Component::Rating(c) => Some(c),
487 Component::Skeleton(c) => Some(c),
488 Component::Slider(c) => Some(c),
489 Component::Sparkline(c) => Some(c),
490 Component::Stat(c) => Some(c),
491 Component::Stepper(c) => Some(c),
492 Component::Switch(c) => Some(c),
493 Component::RichText(c) => Some(c),
494 Component::Table(c) => Some(c),
495 Component::TagCloud(c) => Some(c),
496 Component::Timeline(c) => Some(c),
497 Component::Tooltip(c) => Some(c),
498 Component::Treemap(c) => Some(c),
499 Component::Container(c) => Some(c),
500 Component::Caption(c) => Some(c),
501 }
502 }
503
504 pub fn as_styled(&self) -> &dyn Styled {
505 match self {
506 Component::AudioSpectrum(c) => c,
507 Component::Waveform(c) => c,
508 Component::Text(c) => c,
509 Component::Shape(c) => c,
510 Component::Image(c) => c,
511 Component::Icon(c) => c,
512 Component::Svg(c) => c,
513 Component::Video(c) => c,
514 Component::Gif(c) => c,
515 Component::Counter(c) => c,
516 Component::Cursor(c) => c,
517 Component::Caption(c) => c,
518 Component::Avatar(c) => c,
519 Component::AvatarGroup(c) => c,
520 Component::Arrow(c) => c,
521 Component::Connector(c) => c,
522 Component::Badge(c) => c,
523 Component::Callout(c) => c,
524 Component::Chart(c) => c,
525 Component::Comparison(c) => c,
526 Component::Countdown(c) => c,
527 Component::Divider(c) => c,
528 Component::DotMap(c) => c,
529 Component::Emitter(c) => c,
530 Component::Gauge(c) => c,
531 Component::GradientText(c) => c,
532 Component::Heatmap(c) => c,
533 Component::Kbd(c) => c,
534 Component::Line(c) => c,
535 Component::List(c) => c,
536 Component::Lottie(c) => c,
537 Component::Marquee(c) => c,
538 Component::Mockup(c) => c,
539 Component::Particle(c) => c,
540 Component::PillNav(c) => c,
541 Component::Progress(c) => c,
542 Component::QrCode(c) => c,
543 Component::NumberWheel(c) => c,
544 Component::SuccessCheck(c) => c,
545 Component::Pointer(c) => c,
546 Component::Rating(c) => c,
547 Component::Skeleton(c) => c,
548 Component::Slider(c) => c,
549 Component::Sparkline(c) => c,
550 Component::Stat(c) => c,
551 Component::Stepper(c) => c,
552 Component::Switch(c) => c,
553 Component::RichText(c) => c,
554 Component::Table(c) => c,
555 Component::TagCloud(c) => c,
556 Component::Timeline(c) => c,
557 Component::Tooltip(c) => c,
558 Component::Treemap(c) => c,
559 Component::Container(c) => c,
560 }
561 }
562
563 pub fn as_painter(&self) -> Option<&dyn Painter> {
564 match self {
565 Component::AudioSpectrum(c) => Some(c),
566 Component::Waveform(c) => Some(c),
567 Component::Container(c) => Some(c),
568 Component::Divider(c) => Some(c),
569 Component::Emitter(c) => Some(c),
570 Component::Shape(c) => Some(c),
571 Component::Image(c) => Some(c),
572 Component::Icon(c) => Some(c),
573 Component::Svg(c) => Some(c),
574 Component::QrCode(c) => Some(c),
575 Component::Gif(c) => Some(c),
576 Component::Video(c) => Some(c),
577 Component::Lottie(c) => Some(c),
578 Component::Cursor(c) => Some(c),
579 Component::Particle(c) => Some(c),
580 Component::Mockup(c) => Some(c),
581 Component::Text(c) => Some(c),
582 Component::Caption(c) => Some(c),
583 Component::Badge(c) => Some(c),
584 Component::Kbd(c) => Some(c),
585 Component::Callout(c) => Some(c),
586 Component::Marquee(c) => Some(c),
587 Component::TagCloud(c) => Some(c),
588 Component::GradientText(c) => Some(c),
589 Component::RichText(c) => Some(c),
590 Component::Switch(c) => Some(c),
591 Component::Slider(c) => Some(c),
592 Component::NumberWheel(c) => Some(c),
593 Component::SuccessCheck(c) => Some(c),
594 Component::Pointer(c) => Some(c),
595 Component::Rating(c) => Some(c),
596 Component::Stepper(c) => Some(c),
597 Component::Comparison(c) => Some(c),
598 Component::Tooltip(c) => Some(c),
599 Component::PillNav(c) => Some(c),
600 Component::List(c) => Some(c),
601 Component::Skeleton(c) => Some(c),
602 Component::Avatar(c) => Some(c),
603 Component::AvatarGroup(c) => Some(c),
604 Component::Timeline(c) => Some(c),
605 Component::Progress(c) => Some(c),
606 Component::Counter(c) => Some(c),
607 Component::Countdown(c) => Some(c),
608 Component::Gauge(c) => Some(c),
609 Component::Sparkline(c) => Some(c),
610 Component::Stat(c) => Some(c),
611 Component::Heatmap(c) => Some(c),
612 Component::Treemap(c) => Some(c),
613 Component::DotMap(c) => Some(c),
614 Component::Table(c) => Some(c),
615 Component::Chart(c) => Some(c),
616 Component::Line(c) => Some(c),
617 Component::Arrow(c) => Some(c),
618 Component::Connector(c) => Some(c),
619 }
620 }
621
622 pub fn paints_its_own_background(&self) -> bool {
623 matches!(
624 self,
625 Component::Badge(_)
626 | Component::Callout(_)
627 | Component::Caption(_)
628 | Component::Kbd(_)
629 | Component::Stat(_)
630 | Component::Tooltip(_)
631 )
632 }
633
634 pub fn with_resolved_background(&self, resolved: &CssStyle) -> Option<Component> {
635 if !self.paints_its_own_background() {
636 return None;
637 }
638 let background = resolved.background.clone()?;
639 if self.as_styled().style_config().background.as_ref() == Some(&background) {
640 return None;
641 }
642 let value = serde_json::to_value(self).ok()?;
643 let mut clone: Component = serde_json::from_value(value).ok()?;
644 let style = match &mut clone {
645 Component::Badge(c) => c.style_config_mut(),
646 Component::Callout(c) => c.style_config_mut(),
647 Component::Caption(c) => c.style_config_mut(),
648 Component::Kbd(c) => c.style_config_mut(),
649 Component::Stat(c) => c.style_config_mut(),
650 Component::Tooltip(c) => c.style_config_mut(),
651 _ => unreachable!("classified by paints_its_own_background above"),
652 };
653 style.background = Some(background);
654 Some(clone)
655 }
656
657 pub fn with_cascaded_style(&self, resolved: &CssStyle) -> Option<Component> {
658 let is_typographic = match self {
659 Component::Text(_)
660 | Component::GradientText(_)
661 | Component::Caption(_)
662 | Component::RichText(_)
663 | Component::Badge(_)
664 | Component::Callout(_)
665 | Component::Counter(_)
666 | Component::Divider(_)
667 | Component::Icon(_)
668 | Component::Kbd(_)
669 | Component::List(_)
670 | Component::Marquee(_)
671 | Component::NumberWheel(_)
672 | Component::PillNav(_)
673 | Component::Table(_)
674 | Component::Tooltip(_) => true,
675 Component::AudioSpectrum(_)
676 | Component::Shape(_)
677 | Component::Image(_)
678 | Component::Svg(_)
679 | Component::Video(_)
680 | Component::Gif(_)
681 | Component::Cursor(_)
682 | Component::Connector(_)
683 | Component::Avatar(_)
684 | Component::AvatarGroup(_)
685 | Component::Arrow(_)
686 | Component::Chart(_)
687 | Component::Comparison(_)
688 | Component::Countdown(_)
689 | Component::DotMap(_)
690 | Component::Emitter(_)
691 | Component::Gauge(_)
692 | Component::Heatmap(_)
693 | Component::Line(_)
694 | Component::Lottie(_)
695 | Component::Mockup(_)
696 | Component::Particle(_)
697 | Component::Progress(_)
698 | Component::QrCode(_)
699 | Component::SuccessCheck(_)
700 | Component::Pointer(_)
701 | Component::Rating(_)
702 | Component::Skeleton(_)
703 | Component::Slider(_)
704 | Component::Sparkline(_)
705 | Component::Stat(_)
706 | Component::Stepper(_)
707 | Component::Switch(_)
708 | Component::TagCloud(_)
709 | Component::Timeline(_)
710 | Component::Treemap(_)
711 | Component::Container(_)
712 | Component::Waveform(_) => false,
713 };
714 if !is_typographic {
715 return None;
716 }
717 let value = serde_json::to_value(self).ok()?;
718 let mut clone: Component = serde_json::from_value(value).ok()?;
719 let style = match &mut clone {
720 Component::Text(c) => c.style_config_mut(),
721 Component::GradientText(c) => c.style_config_mut(),
722 Component::Caption(c) => c.style_config_mut(),
723 Component::RichText(c) => c.style_config_mut(),
724 Component::Badge(c) => c.style_config_mut(),
725 Component::Callout(c) => c.style_config_mut(),
726 Component::Counter(c) => c.style_config_mut(),
727 Component::Divider(c) => c.style_config_mut(),
728 Component::Icon(c) => c.style_config_mut(),
729 Component::Kbd(c) => c.style_config_mut(),
730 Component::List(c) => c.style_config_mut(),
731 Component::Marquee(c) => c.style_config_mut(),
732 Component::NumberWheel(c) => c.style_config_mut(),
733 Component::PillNav(c) => c.style_config_mut(),
734 Component::Table(c) => c.style_config_mut(),
735 Component::Tooltip(c) => c.style_config_mut(),
736 _ => unreachable!("classified as typographic by the match above"),
737 };
738 rustmotion_core::css::cascade::overlay_resolved_typography(resolved, style);
739 Some(clone)
740 }
741}
742
743#[cfg(test)]
744mod with_cascaded_style_tests {
745 use super::*;
746 use rustmotion_core::css::units::Length;
747
748 #[test]
749 fn a_declared_static_font_size_does_not_shadow_the_resolved_animated_one() {
750 let text: Component = serde_json::from_value(serde_json::json!({
751 "type": "text",
752 "content": "hi",
753 "style": { "font-size": 20 }
754 }))
755 .expect("component deserializes");
756
757 let resolved = CssStyle {
758 font_size: Some(Length::Px(120.0)),
759 ..text.as_styled().style_config().clone()
760 };
761
762 let cascaded = text
763 .with_cascaded_style(&resolved)
764 .expect("text is typographic");
765 assert_eq!(
766 cascaded.as_styled().style_config().font_size,
767 Some(Length::Px(120.0)),
768 "measurement/paint must see the resolved (animated) font-size, not the component's \
769 own declared 20px — this is the exact bug behind issues #426/#430"
770 );
771 }
772
773 #[test]
774 fn a_component_with_no_static_font_size_still_inherits_the_resolved_one() {
775 let text: Component = serde_json::from_value(serde_json::json!({
776 "type": "text",
777 "content": "hi"
778 }))
779 .expect("component deserializes");
780
781 let resolved = CssStyle {
782 font_size: Some(Length::Px(64.0)),
783 ..text.as_styled().style_config().clone()
784 };
785
786 let cascaded = text
787 .with_cascaded_style(&resolved)
788 .expect("text is typographic");
789 assert_eq!(
790 cascaded.as_styled().style_config().font_size,
791 Some(Length::Px(64.0))
792 );
793 }
794}