Skip to main content

omp_tui/
props.rs

1//! Typed component properties with allocation-free well-known slots.
2
3use 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/// A well-known component property.
16///
17/// The markup attribute name of every variant is its kebab-cased ident
18/// (`PadX` ⇒ `pad-x`), parsed by [`Props::prop_of`] and emitted by
19/// `Display`; `VAlign` alone overrides the derived `v-align` to keep the
20/// established `valign`.
21#[repr(u8)]
22#[derive(Clone, Copy, Debug, Eq, PartialEq, Display, EnumIter, EnumString, FromRepr)]
23#[strum(serialize_all = "kebab-case")]
24pub enum Prop {
25	/// Space between adjacent children.
26	Gap,
27	/// Shorthand padding applied to both axes.
28	Pad,
29	/// Horizontal inner padding.
30	PadX,
31	/// Vertical inner padding.
32	PadY,
33	/// Flexible share of remaining layout space.
34	Grow,
35	/// Preferred width in cells or percent.
36	W,
37	/// Minimum width or numeric field value.
38	Min,
39	/// Maximum width or numeric field value.
40	Max,
41	/// Preferred height in rows.
42	H,
43	/// Border glyph family.
44	Border,
45	/// Border color or gradient.
46	Bc,
47	/// Alternate name for the border color or gradient.
48	Edge,
49	/// Extends the background through border cells.
50	Bleed,
51	/// Display title for a container or step.
52	Title,
53	/// Horizontal placement of the border title.
54	TitleAlign,
55	/// Display footer on a framed container's bottom edge.
56	Footer,
57	/// Horizontal placement of the border footer.
58	FooterAlign,
59	/// Horizontal content alignment.
60	Align,
61	/// Vertical content alignment.
62	#[strum(serialize = "valign")]
63	VAlign,
64	/// Distribution of children along the layout axis.
65	Justify,
66	/// Foreground color, theme token, or gradient.
67	Fg,
68	/// Background color, theme token, or gradient.
69	Bg,
70	/// Shorthand background color, theme token, or gradient.
71	On,
72	/// Enables bold text.
73	Bold,
74	/// Enables dim text.
75	Dim,
76	/// Enables italic text.
77	Italic,
78	/// Enables underlined text.
79	Underline,
80	/// Swaps foreground and background colors.
81	Reverse,
82	/// Enables struck-through text.
83	Strike,
84	/// Enables wrapping rows; on text, a value selects the wrapping mode.
85	Wrap,
86	/// Enables text truncation.
87	Truncate,
88	/// Crops transparent image margins before cell sampling.
89	Trim,
90	/// Stable identifier used by updates and conditions.
91	Id,
92	/// Visibility condition referencing another component value.
93	When,
94	/// Initial or submitted field value.
95	Value,
96	/// Space-delimited choices for a selection field.
97	Options,
98	/// User-facing field or item label.
99	Label,
100	/// Supporting description for an option.
101	Desc,
102	/// Field control kind.
103	Kind,
104	/// Numeric increment or wizard step metadata.
105	Step,
106	/// Enables multiple selection.
107	Multi,
108	/// Enables interactive option filtering.
109	Filter,
110	/// Allows values outside the listed options.
111	Custom,
112	/// Obscures input contents.
113	Mask,
114	/// Marks an option as the recommended default.
115	Recommended,
116	/// Expands a tree node initially.
117	Open,
118	/// Requires a nonempty field value.
119	Required,
120	/// Pattern that a field value must satisfy.
121	Match,
122	/// Image or external content source.
123	Src,
124	/// Leading icon name.
125	Icon,
126	/// Compact status label.
127	Badge,
128	/// Emits a submit event when activated.
129	Submit,
130	/// Emits a cancel event when activated.
131	Cancel,
132	/// Requires a second activation before committing.
133	Confirm,
134	/// Hint shown by an empty input.
135	Placeholder,
136	/// Gradient direction in screen degrees.
137	Angle,
138	/// Applies accent styling to an action.
139	Accent,
140	/// Selects vertical rendering where supported.
141	Vertical,
142	/// Transition duration for animatable properties.
143	Anim,
144	/// Easing curve applied to `anim` transitions.
145	Ease,
146	/// Gradient rotation period.
147	Spin,
148	/// Border color or gradient applied while the pointer rests on the
149	/// component or one of its descendants.
150	Hover,
151	/// Rows the component rises toward while hovered.
152	Lift,
153	/// Opts the component into the keyboard focus ring.
154	Focus,
155	/// Tree guide connector family; a bare flag selects the square set.
156	Guides,
157	/// Task lifecycle state on a todo item.
158	Status,
159	/// Sweep period of the brightness crest across text content.
160	Shimmer,
161	/// Catch-up horizon for progressively revealed streamed text.
162	Reveal,
163}
164
165/// Invalid numeric property discriminant.
166#[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/// A parsed component property value.
192#[derive(Clone, Debug, PartialEq)]
193pub enum PropValue {
194	/// Boolean flag.
195	Bool(bool),
196	/// Unsigned cell count or angle.
197	U16(u16),
198	/// Floating-point layout weight.
199	F32(f32),
200	/// Signed numeric field value.
201	I64(i64),
202	/// Resolved terminal color.
203	Color(Color),
204	/// Theme color token resolved at render time.
205	Token(Str),
206	/// A validated `start..end` ramp resolved by the renderer's theme.
207	Gradient(Str),
208	/// Cell or percentage dimension.
209	Dim(Dim),
210	/// Border glyph family.
211	Border(Border),
212	/// Horizontal alignment.
213	Align(Align),
214	/// Vertical alignment.
215	VAlign(VAlign),
216	/// Child distribution along the layout axis.
217	Justify(Justify),
218	/// Uninterpreted textual value.
219	Str(Str),
220	/// Easing curve for `anim` transitions.
221	Easing(Easing),
222}
223
224/// A property value rejected by the key-aware parser.
225#[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/// Typed component attributes.
239#[derive(Clone, Debug, Default)]
240pub struct Props {
241	known:  SparseMap<Prop, PropValue>,
242	custom: Vec<(Str, PropValue)>,
243}
244
245impl Props {
246	/// Creates an empty property collection.
247	pub fn new() -> Self {
248		Self::default()
249	}
250
251	/// Returns this collection with a known property assigned.
252	///
253	/// # Panics
254	///
255	/// Panics when a textual value is invalid for the selected property.
256	pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
257		self.set(prop, value);
258		self
259	}
260
261	/// Assigns a known property.
262	///
263	/// # Panics
264	///
265	/// Panics when a textual value is invalid for the selected property.
266	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	/// Validates and assigns a known property.
273	///
274	/// # Errors
275	///
276	/// Returns `PropError` when a textual value cannot be parsed for the
277	/// selected property.
278	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	/// Returns the typed value assigned to a known property.
310	pub fn get(&self, prop: Prop) -> Option<&PropValue> {
311		self.known.get(prop)
312	}
313
314	/// Removes a known property, restoring its unset default.
315	pub fn unset(&mut self, prop: Prop) {
316		self.known.remove(prop);
317	}
318
319	/// Formats a known property value using its markup representation.
320	pub fn get_str(&self, prop: Prop) -> Option<Str> {
321		self.get(prop).map(display_value)
322	}
323
324	/// Returns this collection with a custom property assigned.
325	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	/// Assigns or replaces a custom property.
331	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	/// Returns a custom property by its literal name.
342	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	/// Returns either a known or custom property by markup name.
351	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	/// Resolves a markup attribute name to its well-known property — the
358	/// kebab-cased variant ident derived on [`Prop`].
359	pub fn prop_of(name: &str) -> Option<Prop> {
360		name.parse().ok()
361	}
362
363	/// Returns the inter-child spacing, defaulting to zero.
364	pub fn gap(&self) -> u16 {
365		self.u16(Prop::Gap).unwrap_or(0)
366	}
367
368	/// Returns vertical and horizontal padding, defaulting to zero.
369	pub fn pad(&self) -> (u16, u16) {
370		(self.u16(Prop::PadY).unwrap_or(0), self.u16(Prop::PadX).unwrap_or(0))
371	}
372
373	/// Returns the flexible growth weight, with a bare flag meaning one.
374	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	/// Returns the preferred width as a cell or percentage dimension.
383	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	/// Returns the minimum width or numeric value.
392	pub fn min(&self) -> Option<u16> {
393		self.u16(Prop::Min)
394	}
395
396	/// Returns the maximum width or numeric value.
397	pub fn max(&self) -> Option<u16> {
398		self.u16(Prop::Max)
399	}
400
401	/// Returns the preferred height in rows.
402	pub fn h(&self) -> Option<u16> {
403		self.u16(Prop::H)
404	}
405
406	/// Returns the truncation mode: a bare `truncate` flag clips the end,
407	/// `truncate=start` clips the beginning; `None` disables truncation.
408	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	/// Whether text flows grapheme-exact to the width (`wrap=char`) like a
418	/// bare terminal: every break is a byte-preserving soft wrap the
419	/// renderer re-joins for native copy. Defaults to word wrapping.
420	pub fn wrap_chars(&self) -> bool {
421		matches!(self.get(Prop::Wrap), Some(PropValue::Str(mode)) if mode == "char")
422	}
423
424	/// Returns the selected border glyph family.
425	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	/// Returns the tree guide connector family; a bare flag means square.
433	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	/// Reports whether the background extends through the border.
442	pub fn bleed(&self) -> bool {
443		self.flag(Prop::Bleed)
444	}
445
446	/// Returns horizontal alignment, defaulting to the start edge.
447	pub fn align(&self) -> Align {
448		self.align_slot(Prop::Align)
449	}
450
451	/// Returns the border-title placement, defaulting to the start edge.
452	pub fn title_align(&self) -> Align {
453		self.align_slot(Prop::TitleAlign)
454	}
455
456	/// Returns the border-footer placement, defaulting to the start edge.
457	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	/// Returns the configured vertical alignment.
469	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	/// Returns the stable component identifier.
477	pub fn id(&self) -> Option<&Str> {
478		self.str_of(Prop::Id)
479	}
480
481	/// Returns the user-facing component title.
482	pub fn title(&self) -> Option<&Str> {
483		self.str_of(Prop::Title)
484	}
485
486	/// Returns the footer shown on a framed container's bottom border.
487	pub fn footer(&self) -> Option<&Str> {
488		self.str_of(Prop::Footer)
489	}
490
491	/// Normalized gradient direction in screen degrees.
492	pub fn angle(&self) -> u16 {
493		self.u16(Prop::Angle).unwrap_or(0)
494	}
495
496	/// Transition duration for animatable properties, when `anim` is set.
497	/// A bare `anim` flag selects 200ms.
498	pub fn anim(&self) -> Option<Duration> {
499		self.duration(Prop::Anim, 200)
500	}
501
502	/// Easing curve for `anim` transitions, defaulting to ease-out — the
503	/// natural shape for state changes that should land softly.
504	pub fn ease(&self) -> Easing {
505		match self.get(Prop::Ease) {
506			Some(PropValue::Easing(value)) => *value,
507			_ => Easing::EaseOut,
508		}
509	}
510
511	/// Gradient rotation period, when `spin` is set. A bare `spin` flag
512	/// selects one revolution every 3 seconds.
513	pub fn spin(&self) -> Option<Duration> {
514		self.duration(Prop::Spin, 3000)
515	}
516
517	/// Brightness-crest sweep period, when `shimmer` is set. A bare
518	/// `shimmer` flag selects one sweep every 2 seconds.
519	pub fn shimmer(&self) -> Option<Duration> {
520		self.duration(Prop::Shimmer, 2000)
521	}
522
523	/// Streamed-text reveal catch-up horizon, when `reveal` is set. A bare
524	/// `reveal` flag selects 250ms; `reveal=0` shows new text immediately.
525	pub fn reveal(&self) -> Option<Duration> {
526		self.duration(Prop::Reveal, 250)
527	}
528
529	/// Rows of hover elevation, with a bare flag meaning one.
530	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	/// Whether hover styling or elevation is declared — the gate for
539	/// registering a pointer zone and resolving hover chrome at paint time.
540	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	/// Reports whether a boolean property is enabled.
560	pub fn flag(&self, prop: Prop) -> bool {
561		matches!(self.get(prop), Some(PropValue::Bool(true)))
562	}
563
564	/// Returns the textual payload of a property.
565	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	/// Resolves colors and text attributes into a render style.
573	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	/// Resolves the border color from either supported attribute name.
608	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		// `truncate` keeps its bare-flag form (end clipping); a value picks
680		// the clipped side.
681		Prop::Truncate => match value.as_str() {
682			"start" | "end" => PropValue::Str(value),
683			_ => return Err(bad()),
684		},
685		// `wrap` stays a bare flag (wrapping rows); on text a value picks
686		// the mode — `wrap=char` flows grapheme-exact like a bare terminal.
687		Prop::Wrap => match value.as_str() {
688			"char" | "word" => PropValue::Str(value),
689			_ => return Err(bad()),
690		},
691		// A textual `filter` seeds the initial query besides enabling
692		// filtering; the bare flag stays a boolean.
693		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
757/// Parses `250`, `250ms`, or `0.4s` into whole milliseconds.
758fn 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		// Bare flags pick the documented defaults; absence disables.
932		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		// Easing defaults to ease-out and parses every token.
945		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}