1use std::{fmt, time::Duration};
4
5use omp_core::{SparseMap, Str, sparse_index::TrySparseIndex};
6use strum::{Display, EnumIter, EnumString, FromRepr};
7
8use crate::{
9 anim::Easing,
10 context::Theme,
11 frame::{Color, Style},
12 markup::{Align, Border, Dim, Justify, Truncate, VAlign},
13};
14
15#[repr(u8)]
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Display, EnumIter, EnumString, FromRepr)]
23#[strum(serialize_all = "kebab-case")]
24pub enum Prop {
25 Gap,
27 Pad,
29 PadX,
31 PadY,
33 Grow,
35 W,
37 Min,
39 Max,
41 H,
43 Border,
45 Bc,
47 Edge,
49 Bleed,
51 Title,
53 TitleAlign,
55 Footer,
57 FooterAlign,
59 Align,
61 #[strum(serialize = "valign")]
63 VAlign,
64 Justify,
66 Fg,
68 Bg,
70 On,
72 Bold,
74 Dim,
76 Italic,
78 Underline,
80 Reverse,
82 Strike,
84 Wrap,
86 Truncate,
88 Trim,
90 Id,
92 When,
94 Value,
96 Options,
98 Label,
100 Desc,
102 Kind,
104 Step,
106 Multi,
108 Filter,
110 Custom,
112 Mask,
114 Recommended,
116 Open,
118 Required,
120 Match,
122 Src,
124 Icon,
126 Badge,
128 Submit,
130 Cancel,
132 Confirm,
134 Placeholder,
136 Angle,
138 Accent,
140 Vertical,
142 Anim,
144 Ease,
146 Spin,
148 Hover,
151 Lift,
153 Focus,
155 Guides,
157 Status,
159 Shimmer,
161 Reveal,
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub struct PropIndexError(usize);
168
169impl fmt::Display for PropIndexError {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 write!(f, "invalid property index {}", self.0)
172 }
173}
174impl std::error::Error for PropIndexError {}
175
176impl TrySparseIndex for Prop {
177 type Error = PropIndexError;
178
179 fn index(&self) -> usize {
180 *self as usize
181 }
182
183 fn try_from_index(index: usize) -> Result<Self, Self::Error> {
184 u8::try_from(index)
185 .ok()
186 .and_then(Self::from_repr)
187 .ok_or(PropIndexError(index))
188 }
189}
190
191#[derive(Clone, Debug, PartialEq)]
193pub enum PropValue {
194 Bool(bool),
196 U16(u16),
198 F32(f32),
200 I64(i64),
202 Color(Color),
204 Token(Str),
206 Gradient(Str),
208 Dim(Dim),
210 Border(Border),
212 Align(Align),
214 VAlign(VAlign),
216 Justify(Justify),
218 Str(Str),
220 Easing(Easing),
222}
223
224#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct PropError {
227 pub prop: Prop,
228 pub value: Str,
229}
230
231impl fmt::Display for PropError {
232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233 write!(f, "bad value {:?} for property {:?}", self.value, self.prop)
234 }
235}
236impl std::error::Error for PropError {}
237
238#[derive(Clone, Debug, Default)]
240pub struct Props {
241 known: SparseMap<Prop, PropValue>,
242 custom: Vec<(Str, PropValue)>,
243}
244
245impl Props {
246 pub fn new() -> Self {
248 Self::default()
249 }
250
251 pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
257 self.set(prop, value);
258 self
259 }
260
261 pub fn set(&mut self, prop: Prop, value: impl Into<PropValue>) {
267 if let Err(error) = self.try_set(prop, value.into()) {
268 panic!("{error}")
269 }
270 }
271
272 pub fn try_set(&mut self, prop: Prop, value: PropValue) -> Result<(), PropError> {
279 if prop == Prop::Pad
280 && let PropValue::Str(value) = &value
281 {
282 let mut parts = value.split_whitespace();
283 let y = parts
284 .next()
285 .unwrap_or("0")
286 .parse()
287 .map_err(|_| PropError { prop, value: value.clone() })?;
288 let x = match parts.next() {
289 Some(part) => part
290 .parse()
291 .map_err(|_| PropError { prop, value: value.clone() })?,
292 None => y,
293 };
294 if parts.next().is_some() {
295 return Err(PropError { prop, value: value.clone() });
296 }
297 self.known.insert(Prop::PadY, PropValue::U16(y));
298 self.known.insert(Prop::PadX, PropValue::U16(x));
299 return Ok(());
300 }
301 let value = match value {
302 PropValue::Str(value) => parse_str(prop, value)?,
303 value => value,
304 };
305 self.known.insert(prop, value);
306 Ok(())
307 }
308
309 pub fn get(&self, prop: Prop) -> Option<&PropValue> {
311 self.known.get(prop)
312 }
313
314 pub fn unset(&mut self, prop: Prop) {
316 self.known.remove(prop);
317 }
318
319 pub fn get_str(&self, prop: Prop) -> Option<Str> {
321 self.get(prop).map(display_value)
322 }
323
324 pub fn with_custom(mut self, name: impl Into<Str>, value: impl Into<PropValue>) -> Self {
326 self.set_custom(name, value);
327 self
328 }
329
330 pub fn set_custom(&mut self, name: impl Into<Str>, value: impl Into<PropValue>) {
332 let name = name.into();
333 let value = value.into();
334 if let Some((_, stored)) = self.custom.iter_mut().find(|(key, _)| key == &name) {
335 *stored = value;
336 } else {
337 self.custom.push((name, value));
338 }
339 }
340
341 pub fn custom(&self, name: &str) -> Option<&PropValue> {
343 self
344 .custom
345 .iter()
346 .find(|(key, _)| key == name)
347 .map(|(_, value)| value)
348 }
349
350 pub fn named(&self, name: &str) -> Option<&PropValue> {
352 Self::prop_of(name)
353 .and_then(|prop| self.get(prop))
354 .or_else(|| self.custom(name))
355 }
356
357 pub fn prop_of(name: &str) -> Option<Prop> {
360 name.parse().ok()
361 }
362
363 pub fn gap(&self) -> u16 {
365 self.u16(Prop::Gap).unwrap_or(0)
366 }
367
368 pub fn pad(&self) -> (u16, u16) {
370 (self.u16(Prop::PadY).unwrap_or(0), self.u16(Prop::PadX).unwrap_or(0))
371 }
372
373 pub fn grow(&self) -> Option<f32> {
375 match self.get(Prop::Grow) {
376 Some(PropValue::F32(value)) => Some(*value),
377 Some(PropValue::Bool(true)) => Some(1.0),
378 _ => None,
379 }
380 }
381
382 pub fn w(&self) -> Option<Dim> {
384 match self.get(Prop::W) {
385 Some(PropValue::U16(value)) => Some(Dim::Cells(*value)),
386 Some(PropValue::Dim(value)) => Some(*value),
387 _ => None,
388 }
389 }
390
391 pub fn min(&self) -> Option<u16> {
393 self.u16(Prop::Min)
394 }
395
396 pub fn max(&self) -> Option<u16> {
398 self.u16(Prop::Max)
399 }
400
401 pub fn h(&self) -> Option<u16> {
403 self.u16(Prop::H)
404 }
405
406 pub fn truncate(&self) -> Option<Truncate> {
409 match self.get(Prop::Truncate) {
410 Some(PropValue::Bool(true)) => Some(Truncate::End),
411 Some(PropValue::Str(side)) if side == "start" => Some(Truncate::Start),
412 Some(PropValue::Str(_)) => Some(Truncate::End),
413 _ => None,
414 }
415 }
416
417 pub fn wrap_chars(&self) -> bool {
421 matches!(self.get(Prop::Wrap), Some(PropValue::Str(mode)) if mode == "char")
422 }
423
424 pub fn border(&self) -> Option<Border> {
426 match self.get(Prop::Border) {
427 Some(PropValue::Border(value)) => Some(*value),
428 _ => None,
429 }
430 }
431
432 pub fn guides(&self) -> Option<Border> {
434 match self.get(Prop::Guides) {
435 Some(PropValue::Border(value)) => Some(*value),
436 Some(PropValue::Bool(true)) => Some(Border::Square),
437 _ => None,
438 }
439 }
440
441 pub fn bleed(&self) -> bool {
443 self.flag(Prop::Bleed)
444 }
445
446 pub fn align(&self) -> Align {
448 self.align_slot(Prop::Align)
449 }
450
451 pub fn title_align(&self) -> Align {
453 self.align_slot(Prop::TitleAlign)
454 }
455
456 pub fn footer_align(&self) -> Align {
458 self.align_slot(Prop::FooterAlign)
459 }
460
461 fn align_slot(&self, prop: Prop) -> Align {
462 match self.get(prop) {
463 Some(PropValue::Align(value)) => *value,
464 _ => Align::Start,
465 }
466 }
467
468 pub fn valign(&self) -> Option<VAlign> {
470 match self.get(Prop::VAlign) {
471 Some(PropValue::VAlign(value)) => Some(*value),
472 _ => None,
473 }
474 }
475
476 pub fn id(&self) -> Option<&Str> {
478 self.str_of(Prop::Id)
479 }
480
481 pub fn title(&self) -> Option<&Str> {
483 self.str_of(Prop::Title)
484 }
485
486 pub fn footer(&self) -> Option<&Str> {
488 self.str_of(Prop::Footer)
489 }
490
491 pub fn angle(&self) -> u16 {
493 self.u16(Prop::Angle).unwrap_or(0)
494 }
495
496 pub fn anim(&self) -> Option<Duration> {
499 self.duration(Prop::Anim, 200)
500 }
501
502 pub fn ease(&self) -> Easing {
505 match self.get(Prop::Ease) {
506 Some(PropValue::Easing(value)) => *value,
507 _ => Easing::EaseOut,
508 }
509 }
510
511 pub fn spin(&self) -> Option<Duration> {
514 self.duration(Prop::Spin, 3000)
515 }
516
517 pub fn shimmer(&self) -> Option<Duration> {
520 self.duration(Prop::Shimmer, 2000)
521 }
522
523 pub fn reveal(&self) -> Option<Duration> {
526 self.duration(Prop::Reveal, 250)
527 }
528
529 pub fn lift(&self) -> u16 {
531 match self.get(Prop::Lift) {
532 Some(PropValue::U16(value)) => *value,
533 Some(PropValue::Bool(true)) => 1,
534 _ => 0,
535 }
536 }
537
538 pub(crate) fn hover_decorated(&self) -> bool {
541 self.get(Prop::Hover).is_some() || self.lift() > 0
542 }
543
544 fn duration(&self, prop: Prop, default_ms: u64) -> Option<Duration> {
545 match self.get(prop)? {
546 PropValue::U16(ms) => Some(Duration::from_millis(u64::from(*ms))),
547 PropValue::Bool(true) => Some(Duration::from_millis(default_ms)),
548 _ => None,
549 }
550 }
551
552 pub(crate) fn gradient_of(&self, prop: Prop) -> Option<&Str> {
553 match self.get(prop) {
554 Some(PropValue::Gradient(value)) => Some(value),
555 _ => None,
556 }
557 }
558
559 pub fn flag(&self, prop: Prop) -> bool {
561 matches!(self.get(prop), Some(PropValue::Bool(true)))
562 }
563
564 pub fn str_of(&self, prop: Prop) -> Option<&Str> {
566 match self.get(prop) {
567 Some(PropValue::Str(value)) => Some(value),
568 _ => None,
569 }
570 }
571
572 pub fn style(&self, theme: &Theme) -> Style {
574 let mut style = Style::new();
575 if let Some(color) = self.color(Prop::Fg, theme) {
576 style = style.fg(color);
577 }
578 let background = if self.get(Prop::Bg).is_some() {
579 self.color(Prop::Bg, theme)
580 } else {
581 self.color(Prop::On, theme)
582 };
583 if let Some(color) = background {
584 style = style.bg(color);
585 }
586 if self.flag(Prop::Bold) {
587 style = style.bold();
588 }
589 if self.flag(Prop::Dim) {
590 style = style.dim();
591 }
592 if self.flag(Prop::Italic) {
593 style = style.italic();
594 }
595 if self.flag(Prop::Underline) {
596 style = style.underline();
597 }
598 if self.flag(Prop::Reverse) {
599 style = style.reverse();
600 }
601 if self.flag(Prop::Strike) {
602 style = style.strikethrough();
603 }
604 style
605 }
606
607 pub fn edge(&self, theme: &Theme) -> Option<Color> {
609 self
610 .color(Prop::Bc, theme)
611 .or_else(|| self.color(Prop::Edge, theme))
612 }
613
614 fn u16(&self, prop: Prop) -> Option<u16> {
615 match self.get(prop) {
616 Some(PropValue::U16(value)) => Some(*value),
617 _ => None,
618 }
619 }
620
621 fn color(&self, prop: Prop, theme: &Theme) -> Option<Color> {
622 match self.get(prop) {
623 Some(PropValue::Color(value)) => Some(*value),
624 Some(PropValue::Token(value)) => theme.token(value),
625 _ => None,
626 }
627 }
628}
629
630fn parse_str(prop: Prop, value: Str) -> Result<PropValue, PropError> {
631 let bad = || PropError { prop, value: value.clone() };
632 Ok(match prop {
633 Prop::Gap | Prop::PadX | Prop::PadY | Prop::Min | Prop::Max | Prop::H | Prop::Lift => {
634 PropValue::U16(value.parse().map_err(|_| bad())?)
635 },
636 Prop::W => {
637 if let Some(percent) = value.strip_suffix("%") {
638 PropValue::Dim(Dim::Pct(percent.parse().map_err(|_| bad())?))
639 } else {
640 PropValue::U16(value.parse().map_err(|_| bad())?)
641 }
642 },
643 Prop::Grow => PropValue::F32(value.parse().map_err(|_| bad())?),
644 Prop::Step => PropValue::I64(value.parse().map_err(|_| bad())?),
645 Prop::Border | Prop::Guides => PropValue::Border(match value.as_str() {
646 "square" => Border::Square,
647 "dash" => Border::Dash,
648 "round" => Border::Round,
649 "heavy" => Border::Heavy,
650 "double" => Border::Double,
651 _ => return Err(bad()),
652 }),
653 Prop::Align | Prop::TitleAlign | Prop::FooterAlign => {
654 PropValue::Align(match value.as_str() {
655 "start" | "left" => Align::Start,
656 "center" | "middle" => Align::Center,
657 "end" | "right" => Align::End,
658 _ => return Err(bad()),
659 })
660 },
661 Prop::VAlign => PropValue::VAlign(match value.as_str() {
662 "start" | "top" => VAlign::Start,
663 "center" | "middle" => VAlign::Center,
664 "end" | "bottom" => VAlign::End,
665 "stretch" | "fill" => VAlign::Stretch,
666 _ => return Err(bad()),
667 }),
668 Prop::Justify => PropValue::Justify(match value.as_str() {
669 "start" => Justify::Start,
670 "center" => Justify::Center,
671 "end" => Justify::End,
672 "between" => Justify::Between,
673 _ => return Err(bad()),
674 }),
675 Prop::Angle => PropValue::U16(parse_angle(&value).ok_or_else(bad)?),
676 Prop::Anim | Prop::Spin | Prop::Shimmer | Prop::Reveal => {
677 PropValue::U16(parse_duration_ms(&value).ok_or_else(bad)?)
678 },
679 Prop::Truncate => match value.as_str() {
682 "start" | "end" => PropValue::Str(value),
683 _ => return Err(bad()),
684 },
685 Prop::Wrap => match value.as_str() {
688 "char" | "word" => PropValue::Str(value),
689 _ => return Err(bad()),
690 },
691 Prop::Filter => PropValue::Str(value),
694 Prop::Ease => PropValue::Easing(match value.as_str() {
695 "linear" => Easing::Linear,
696 "in" => Easing::EaseIn,
697 "out" => Easing::EaseOut,
698 "in-out" => Easing::EaseInOut,
699 _ => return Err(bad()),
700 }),
701 Prop::Fg | Prop::Bg | Prop::On | Prop::Bc | Prop::Edge | Prop::Hover => {
702 if is_gradient(&value) {
703 PropValue::Gradient(value)
704 } else if is_theme_token(&value) {
705 PropValue::Token(value)
706 } else if let Some(color) = Color::parse(&value) {
707 PropValue::Color(color)
708 } else {
709 return Err(bad());
710 }
711 },
712 Prop::Bold
713 | Prop::Dim
714 | Prop::Italic
715 | Prop::Underline
716 | Prop::Reverse
717 | Prop::Strike
718 | Prop::Trim
719 | Prop::Bleed
720 | Prop::Multi
721 | Prop::Custom
722 | Prop::Mask
723 | Prop::Recommended
724 | Prop::Open
725 | Prop::Required
726 | Prop::Submit
727 | Prop::Cancel
728 | Prop::Confirm
729 | Prop::Accent
730 | Prop::Vertical
731 | Prop::Focus => PropValue::Bool(true),
732 _ => PropValue::Str(value),
733 })
734}
735
736fn is_theme_token(value: &str) -> bool {
737 Theme::is_token(value)
738}
739
740fn is_gradient(value: &str) -> bool {
741 let Some((start, end)) = value.split_once("..") else {
742 return false;
743 };
744 is_color(start) && is_color(end)
745}
746
747fn is_color(value: &str) -> bool {
748 is_theme_token(value) || Color::parse(value).is_some()
749}
750
751fn parse_angle(value: &str) -> Option<u16> {
752 let value = value.trim();
753 let value = value.strip_suffix("deg").unwrap_or(value);
754 Some(value.parse::<i32>().ok()?.rem_euclid(360) as u16)
755}
756
757fn parse_duration_ms(value: &str) -> Option<u16> {
759 let value = value.trim();
760 if let Some(millis) = value.strip_suffix("ms") {
761 return millis.trim().parse().ok();
762 }
763 if let Some(seconds) = value.strip_suffix('s') {
764 let seconds: f32 = seconds.trim().parse().ok()?;
765 if !(0.0..=65.0).contains(&seconds) {
766 return None;
767 }
768 return Some((seconds * 1000.0).round() as u16);
769 }
770 value.parse().ok()
771}
772
773fn display_value(value: &PropValue) -> Str {
774 match value {
775 PropValue::Bool(value) => Str::new(if *value { "true" } else { "false" }),
776 PropValue::U16(value) => Str::from(value.to_string()),
777 PropValue::F32(value) => Str::from(value.to_string()),
778 PropValue::I64(value) => Str::from(value.to_string()),
779 PropValue::Color(Color::Default) => Str::new_static("default"),
780 PropValue::Color(Color::Indexed(value)) => Str::from(value.to_string()),
781 PropValue::Color(Color::Rgb(r, g, b)) => Str::from(format!("#{r:02x}{g:02x}{b:02x}")),
782 PropValue::Token(value) | PropValue::Gradient(value) | PropValue::Str(value) => value.clone(),
783 PropValue::Easing(value) => Str::new_static(match value {
784 Easing::Linear => "linear",
785 Easing::EaseIn => "in",
786 Easing::EaseOut => "out",
787 Easing::EaseInOut => "in-out",
788 }),
789 PropValue::Dim(Dim::Cells(value)) => Str::from(value.to_string()),
790 PropValue::Dim(Dim::Pct(value)) => Str::from(format!("{value}%")),
791 PropValue::Border(value) => Str::new_static(match value {
792 Border::Square => "square",
793 Border::Dash => "dash",
794 Border::Round => "round",
795 Border::Heavy => "heavy",
796 Border::Double => "double",
797 }),
798 PropValue::Align(value) => Str::new_static(match value {
799 Align::Start => "start",
800 Align::Center => "center",
801 Align::End => "end",
802 }),
803 PropValue::VAlign(value) => Str::new_static(match value {
804 VAlign::Start => "start",
805 VAlign::Center => "center",
806 VAlign::End => "end",
807 VAlign::Stretch => "stretch",
808 }),
809 PropValue::Justify(value) => Str::new_static(match value {
810 Justify::Start => "start",
811 Justify::Center => "center",
812 Justify::End => "end",
813 Justify::Between => "between",
814 }),
815 }
816}
817
818macro_rules! from_value {
819 ($type:ty, $variant:ident) => {
820 impl From<$type> for PropValue {
821 fn from(value: $type) -> Self {
822 Self::$variant(value)
823 }
824 }
825 };
826}
827from_value!(Color, Color);
828from_value!(bool, Bool);
829from_value!(u16, U16);
830from_value!(f32, F32);
831from_value!(i64, I64);
832from_value!(Str, Str);
833from_value!(Dim, Dim);
834from_value!(Border, Border);
835from_value!(Align, Align);
836from_value!(VAlign, VAlign);
837from_value!(Justify, Justify);
838from_value!(Easing, Easing);
839impl From<&str> for PropValue {
840 fn from(value: &str) -> Self {
841 Self::Str(Str::new(value))
842 }
843}
844impl From<String> for PropValue {
845 fn from(value: String) -> Self {
846 Self::Str(value.into())
847 }
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn known_values_parse_at_set_time() {
856 assert_eq!(
857 Props::new().with(Prop::Fg, "blue").get(Prop::Fg),
858 Some(&PropValue::Color(Color::Rgb(0, 0, 255)))
859 );
860 assert_eq!(
861 Props::new().with(Prop::Fg, "accent").get(Prop::Fg),
862 Some(&PropValue::Token(Str::new("accent")))
863 );
864 assert_eq!(
865 Props::new().with(Prop::Title, "x").get(Prop::Title),
866 Some(&PropValue::Str(Str::new("x")))
867 );
868 }
869
870 #[test]
871 fn gradients_and_angles_use_standard_color_properties() {
872 let props = Props::new()
873 .with(Prop::Bg, "accent..info")
874 .with(Prop::Fg, "#000000..#ffffff")
875 .with(Prop::Angle, "-90deg");
876 assert_eq!(props.get(Prop::Bg), Some(&PropValue::Gradient(Str::new("accent..info"))));
877 assert_eq!(props.get(Prop::Fg), Some(&PropValue::Gradient(Str::new("#000000..#ffffff"))));
878 assert_eq!(props.angle(), 270);
879 assert!(Props::prop_of("gradient").is_none());
880 assert!(Props::prop_of("dir").is_none());
881 }
882
883 #[test]
884 #[should_panic(expected = "nosuch")]
885 fn invalid_known_value_panics() {
886 let _ = Props::new().with(Prop::Fg, "nosuch");
887 }
888
889 #[test]
890 fn invalid_known_value_is_fallible() {
891 let mut props = Props::new();
892 assert!(props.try_set(Prop::Fg, PropValue::from("nosuch")).is_err());
893 }
894
895 #[test]
896 fn values_format_and_customs_round_trip() {
897 let props = Props::new()
898 .with(Prop::Gap, 2_u16)
899 .with_custom("data-x", "1");
900 assert_eq!(props.get_str(Prop::Gap).as_deref(), Some("2"));
901 assert_eq!(props.custom("data-x"), Some(&PropValue::Str(Str::new("1"))));
902 assert_eq!(props.named("data-x"), props.custom("data-x"));
903 }
904
905 #[test]
906 fn style_resolves_tokens_at_read_time() {
907 let theme = Theme { accent: Color::Rgb(1, 2, 3), ..Theme::default() };
908 let props = Props::new().with(Prop::Fg, "accent").with(Prop::Bold, true);
909 assert_eq!(props.style(&theme).foreground_color(), Color::Rgb(1, 2, 3));
910 assert_eq!(props.get(Prop::Bold), Some(&PropValue::Bool(true)));
911 assert!(props.flag(Prop::Bold));
912 assert!(!Props::new().with(Prop::Bold, false).flag(Prop::Bold));
913 }
914
915 #[test]
916 fn anim_props_parse_durations_and_easing() {
917 let mut props = Props::new();
918 props.set(Prop::Anim, "150ms");
919 assert_eq!(props.anim(), Some(Duration::from_millis(150)));
920 props.set(Prop::Anim, "0.4s");
921 assert_eq!(props.anim(), Some(Duration::from_millis(400)));
922 props.set(Prop::Anim, "250");
923 assert_eq!(props.anim(), Some(Duration::from_millis(250)));
924 props.set(Prop::Spin, "2s");
925 assert_eq!(props.spin(), Some(Duration::from_millis(2000)));
926 props.set(Prop::Shimmer, "1.5s");
927 assert_eq!(props.shimmer(), Some(Duration::from_millis(1500)));
928 props.set(Prop::Reveal, "500ms");
929 assert_eq!(props.reveal(), Some(Duration::from_millis(500)));
930
931 let bare = Props::new()
933 .with(Prop::Anim, true)
934 .with(Prop::Spin, true)
935 .with(Prop::Shimmer, true)
936 .with(Prop::Reveal, true);
937 assert_eq!(bare.anim(), Some(Duration::from_millis(200)));
938 assert_eq!(bare.spin(), Some(Duration::from_millis(3000)));
939 assert_eq!(bare.shimmer(), Some(Duration::from_millis(2000)));
940 assert_eq!(bare.reveal(), Some(Duration::from_millis(250)));
941 assert_eq!(Props::new().reveal(), None);
942 assert_eq!(Props::new().anim(), None);
943
944 assert_eq!(props.ease(), Easing::EaseOut);
946 props.set(Prop::Ease, "in-out");
947 assert_eq!(props.ease(), Easing::EaseInOut);
948 assert_eq!(props.get_str(Prop::Ease).as_deref(), Some("in-out"));
949 assert!(
950 props
951 .try_set(Prop::Ease, PropValue::from("bouncy"))
952 .is_err()
953 );
954 assert!(props.try_set(Prop::Anim, PropValue::from("fast")).is_err());
955 assert!(props.try_set(Prop::Spin, PropValue::from("99s")).is_err());
956 }
957
958 #[test]
959 fn prop_indices_round_trip_through_the_catalog() {
960 use strum::IntoEnumIterator as _;
961 for (index, prop) in Prop::iter().enumerate() {
962 assert_eq!(prop as usize, index, "the catalog diverges from enum order at {prop:?}");
963 assert_eq!(Prop::try_from_index(index), Ok(prop));
964 }
965 }
966}