Skip to main content

rustmotion_components/
lib.rs

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    /// Author-declared name for this node, unique within its own scene
271    /// (issue #328). Read back by other nodes' expressions through
272    /// `node("id", "prop")` — see `rustmotion_core::engine::deps`'s module
273    /// doc for the per-frame dependency graph this feeds, and the "unique
274    /// within its scene" constraint that graph enforces at build time
275    /// (`DepsError::DuplicateId`). Optional: a node with no `id` simply
276    /// cannot be referenced by another one's expressions, and is otherwise
277    /// unaffected.
278    #[serde(default)]
279    pub id: Option<String>,
280    /// Declares that this component's job is to extend past the frame edge
281    /// (e.g. a radial glow used as a base layer). Top-level field, not a
282    /// `style` property — `CssStyle` is `deny_unknown_fields` and belongs to
283    /// no one this wave. Defaults to `false`: no existing scenario changes
284    /// behaviour. Exempts only `viewport_overflow` and `animated_text_overflow`
285    /// (see `crates/rustmotion/src/cli/commands/geometry.rs`); it does NOT
286    /// exempt `content_overflows_box` — content larger than its own box stays
287    /// a reported defect regardless of `bleed`. Applies to this component
288    /// only: a bled container does not suppress checks on its children, since
289    /// each child is its own `ChildComponent` with its own `bleed` flag.
290    #[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}