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