use std::{fmt, time::Duration};
use omp_core::{SparseMap, Str, sparse_index::TrySparseIndex};
use strum::{Display, EnumIter, EnumString, FromRepr};
use crate::{
anim::Easing,
context::Theme,
frame::{Color, Style},
markup::{Align, Border, Dim, Justify, Truncate, VAlign},
};
#[repr(u8)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Display, EnumIter, EnumString, FromRepr)]
#[strum(serialize_all = "kebab-case")]
pub enum Prop {
Gap,
Pad,
PadX,
PadY,
Grow,
W,
Min,
Max,
H,
Border,
Bc,
Edge,
Bleed,
Title,
TitleAlign,
Footer,
FooterAlign,
Align,
#[strum(serialize = "valign")]
VAlign,
Justify,
Fg,
Bg,
On,
Bold,
Dim,
Italic,
Underline,
Reverse,
Strike,
Wrap,
Truncate,
Trim,
Id,
When,
Value,
Options,
Label,
Desc,
Kind,
Step,
Multi,
Filter,
Custom,
Mask,
Recommended,
Open,
Required,
Match,
Src,
Icon,
Badge,
Submit,
Cancel,
Confirm,
Placeholder,
Angle,
Accent,
Vertical,
Anim,
Ease,
Spin,
Hover,
Lift,
Focus,
Guides,
Status,
Shimmer,
Reveal,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PropIndexError(usize);
impl fmt::Display for PropIndexError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid property index {}", self.0)
}
}
impl std::error::Error for PropIndexError {}
impl TrySparseIndex for Prop {
type Error = PropIndexError;
fn index(&self) -> usize {
*self as usize
}
fn try_from_index(index: usize) -> Result<Self, Self::Error> {
u8::try_from(index)
.ok()
.and_then(Self::from_repr)
.ok_or(PropIndexError(index))
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum PropValue {
Bool(bool),
U16(u16),
F32(f32),
I64(i64),
Color(Color),
Token(Str),
Gradient(Str),
Dim(Dim),
Border(Border),
Align(Align),
VAlign(VAlign),
Justify(Justify),
Str(Str),
Easing(Easing),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PropError {
pub prop: Prop,
pub value: Str,
}
impl fmt::Display for PropError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "bad value {:?} for property {:?}", self.value, self.prop)
}
}
impl std::error::Error for PropError {}
#[derive(Clone, Debug, Default)]
pub struct Props {
known: SparseMap<Prop, PropValue>,
custom: Vec<(Str, PropValue)>,
}
impl Props {
pub fn new() -> Self {
Self::default()
}
pub fn with(mut self, prop: Prop, value: impl Into<PropValue>) -> Self {
self.set(prop, value);
self
}
pub fn set(&mut self, prop: Prop, value: impl Into<PropValue>) {
if let Err(error) = self.try_set(prop, value.into()) {
panic!("{error}")
}
}
pub fn try_set(&mut self, prop: Prop, value: PropValue) -> Result<(), PropError> {
if prop == Prop::Pad
&& let PropValue::Str(value) = &value
{
let mut parts = value.split_whitespace();
let y = parts
.next()
.unwrap_or("0")
.parse()
.map_err(|_| PropError { prop, value: value.clone() })?;
let x = match parts.next() {
Some(part) => part
.parse()
.map_err(|_| PropError { prop, value: value.clone() })?,
None => y,
};
if parts.next().is_some() {
return Err(PropError { prop, value: value.clone() });
}
self.known.insert(Prop::PadY, PropValue::U16(y));
self.known.insert(Prop::PadX, PropValue::U16(x));
return Ok(());
}
let value = match value {
PropValue::Str(value) => parse_str(prop, value)?,
value => value,
};
self.known.insert(prop, value);
Ok(())
}
pub fn get(&self, prop: Prop) -> Option<&PropValue> {
self.known.get(prop)
}
pub fn unset(&mut self, prop: Prop) {
self.known.remove(prop);
}
pub fn get_str(&self, prop: Prop) -> Option<Str> {
self.get(prop).map(display_value)
}
pub fn with_custom(mut self, name: impl Into<Str>, value: impl Into<PropValue>) -> Self {
self.set_custom(name, value);
self
}
pub fn set_custom(&mut self, name: impl Into<Str>, value: impl Into<PropValue>) {
let name = name.into();
let value = value.into();
if let Some((_, stored)) = self.custom.iter_mut().find(|(key, _)| key == &name) {
*stored = value;
} else {
self.custom.push((name, value));
}
}
pub fn custom(&self, name: &str) -> Option<&PropValue> {
self
.custom
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value)
}
pub fn named(&self, name: &str) -> Option<&PropValue> {
Self::prop_of(name)
.and_then(|prop| self.get(prop))
.or_else(|| self.custom(name))
}
pub fn prop_of(name: &str) -> Option<Prop> {
name.parse().ok()
}
pub fn gap(&self) -> u16 {
self.u16(Prop::Gap).unwrap_or(0)
}
pub fn pad(&self) -> (u16, u16) {
(self.u16(Prop::PadY).unwrap_or(0), self.u16(Prop::PadX).unwrap_or(0))
}
pub fn grow(&self) -> Option<f32> {
match self.get(Prop::Grow) {
Some(PropValue::F32(value)) => Some(*value),
Some(PropValue::Bool(true)) => Some(1.0),
_ => None,
}
}
pub fn w(&self) -> Option<Dim> {
match self.get(Prop::W) {
Some(PropValue::U16(value)) => Some(Dim::Cells(*value)),
Some(PropValue::Dim(value)) => Some(*value),
_ => None,
}
}
pub fn min(&self) -> Option<u16> {
self.u16(Prop::Min)
}
pub fn max(&self) -> Option<u16> {
self.u16(Prop::Max)
}
pub fn h(&self) -> Option<u16> {
self.u16(Prop::H)
}
pub fn truncate(&self) -> Option<Truncate> {
match self.get(Prop::Truncate) {
Some(PropValue::Bool(true)) => Some(Truncate::End),
Some(PropValue::Str(side)) if side == "start" => Some(Truncate::Start),
Some(PropValue::Str(_)) => Some(Truncate::End),
_ => None,
}
}
pub fn wrap_chars(&self) -> bool {
matches!(self.get(Prop::Wrap), Some(PropValue::Str(mode)) if mode == "char")
}
pub fn border(&self) -> Option<Border> {
match self.get(Prop::Border) {
Some(PropValue::Border(value)) => Some(*value),
_ => None,
}
}
pub fn guides(&self) -> Option<Border> {
match self.get(Prop::Guides) {
Some(PropValue::Border(value)) => Some(*value),
Some(PropValue::Bool(true)) => Some(Border::Square),
_ => None,
}
}
pub fn bleed(&self) -> bool {
self.flag(Prop::Bleed)
}
pub fn align(&self) -> Align {
self.align_slot(Prop::Align)
}
pub fn title_align(&self) -> Align {
self.align_slot(Prop::TitleAlign)
}
pub fn footer_align(&self) -> Align {
self.align_slot(Prop::FooterAlign)
}
fn align_slot(&self, prop: Prop) -> Align {
match self.get(prop) {
Some(PropValue::Align(value)) => *value,
_ => Align::Start,
}
}
pub fn valign(&self) -> Option<VAlign> {
match self.get(Prop::VAlign) {
Some(PropValue::VAlign(value)) => Some(*value),
_ => None,
}
}
pub fn id(&self) -> Option<&Str> {
self.str_of(Prop::Id)
}
pub fn title(&self) -> Option<&Str> {
self.str_of(Prop::Title)
}
pub fn footer(&self) -> Option<&Str> {
self.str_of(Prop::Footer)
}
pub fn angle(&self) -> u16 {
self.u16(Prop::Angle).unwrap_or(0)
}
pub fn anim(&self) -> Option<Duration> {
self.duration(Prop::Anim, 200)
}
pub fn ease(&self) -> Easing {
match self.get(Prop::Ease) {
Some(PropValue::Easing(value)) => *value,
_ => Easing::EaseOut,
}
}
pub fn spin(&self) -> Option<Duration> {
self.duration(Prop::Spin, 3000)
}
pub fn shimmer(&self) -> Option<Duration> {
self.duration(Prop::Shimmer, 2000)
}
pub fn reveal(&self) -> Option<Duration> {
self.duration(Prop::Reveal, 250)
}
pub fn lift(&self) -> u16 {
match self.get(Prop::Lift) {
Some(PropValue::U16(value)) => *value,
Some(PropValue::Bool(true)) => 1,
_ => 0,
}
}
pub(crate) fn hover_decorated(&self) -> bool {
self.get(Prop::Hover).is_some() || self.lift() > 0
}
fn duration(&self, prop: Prop, default_ms: u64) -> Option<Duration> {
match self.get(prop)? {
PropValue::U16(ms) => Some(Duration::from_millis(u64::from(*ms))),
PropValue::Bool(true) => Some(Duration::from_millis(default_ms)),
_ => None,
}
}
pub(crate) fn gradient_of(&self, prop: Prop) -> Option<&Str> {
match self.get(prop) {
Some(PropValue::Gradient(value)) => Some(value),
_ => None,
}
}
pub fn flag(&self, prop: Prop) -> bool {
matches!(self.get(prop), Some(PropValue::Bool(true)))
}
pub fn str_of(&self, prop: Prop) -> Option<&Str> {
match self.get(prop) {
Some(PropValue::Str(value)) => Some(value),
_ => None,
}
}
pub fn style(&self, theme: &Theme) -> Style {
let mut style = Style::new();
if let Some(color) = self.color(Prop::Fg, theme) {
style = style.fg(color);
}
let background = if self.get(Prop::Bg).is_some() {
self.color(Prop::Bg, theme)
} else {
self.color(Prop::On, theme)
};
if let Some(color) = background {
style = style.bg(color);
}
if self.flag(Prop::Bold) {
style = style.bold();
}
if self.flag(Prop::Dim) {
style = style.dim();
}
if self.flag(Prop::Italic) {
style = style.italic();
}
if self.flag(Prop::Underline) {
style = style.underline();
}
if self.flag(Prop::Reverse) {
style = style.reverse();
}
if self.flag(Prop::Strike) {
style = style.strikethrough();
}
style
}
pub fn edge(&self, theme: &Theme) -> Option<Color> {
self
.color(Prop::Bc, theme)
.or_else(|| self.color(Prop::Edge, theme))
}
fn u16(&self, prop: Prop) -> Option<u16> {
match self.get(prop) {
Some(PropValue::U16(value)) => Some(*value),
_ => None,
}
}
fn color(&self, prop: Prop, theme: &Theme) -> Option<Color> {
match self.get(prop) {
Some(PropValue::Color(value)) => Some(*value),
Some(PropValue::Token(value)) => theme.token(value),
_ => None,
}
}
}
fn parse_str(prop: Prop, value: Str) -> Result<PropValue, PropError> {
let bad = || PropError { prop, value: value.clone() };
Ok(match prop {
Prop::Gap | Prop::PadX | Prop::PadY | Prop::Min | Prop::Max | Prop::H | Prop::Lift => {
PropValue::U16(value.parse().map_err(|_| bad())?)
},
Prop::W => {
if let Some(percent) = value.strip_suffix("%") {
PropValue::Dim(Dim::Pct(percent.parse().map_err(|_| bad())?))
} else {
PropValue::U16(value.parse().map_err(|_| bad())?)
}
},
Prop::Grow => PropValue::F32(value.parse().map_err(|_| bad())?),
Prop::Step => PropValue::I64(value.parse().map_err(|_| bad())?),
Prop::Border | Prop::Guides => PropValue::Border(match value.as_str() {
"square" => Border::Square,
"dash" => Border::Dash,
"round" => Border::Round,
"heavy" => Border::Heavy,
"double" => Border::Double,
_ => return Err(bad()),
}),
Prop::Align | Prop::TitleAlign | Prop::FooterAlign => {
PropValue::Align(match value.as_str() {
"start" | "left" => Align::Start,
"center" | "middle" => Align::Center,
"end" | "right" => Align::End,
_ => return Err(bad()),
})
},
Prop::VAlign => PropValue::VAlign(match value.as_str() {
"start" | "top" => VAlign::Start,
"center" | "middle" => VAlign::Center,
"end" | "bottom" => VAlign::End,
"stretch" | "fill" => VAlign::Stretch,
_ => return Err(bad()),
}),
Prop::Justify => PropValue::Justify(match value.as_str() {
"start" => Justify::Start,
"center" => Justify::Center,
"end" => Justify::End,
"between" => Justify::Between,
_ => return Err(bad()),
}),
Prop::Angle => PropValue::U16(parse_angle(&value).ok_or_else(bad)?),
Prop::Anim | Prop::Spin | Prop::Shimmer | Prop::Reveal => {
PropValue::U16(parse_duration_ms(&value).ok_or_else(bad)?)
},
Prop::Truncate => match value.as_str() {
"start" | "end" => PropValue::Str(value),
_ => return Err(bad()),
},
Prop::Wrap => match value.as_str() {
"char" | "word" => PropValue::Str(value),
_ => return Err(bad()),
},
Prop::Filter => PropValue::Str(value),
Prop::Ease => PropValue::Easing(match value.as_str() {
"linear" => Easing::Linear,
"in" => Easing::EaseIn,
"out" => Easing::EaseOut,
"in-out" => Easing::EaseInOut,
_ => return Err(bad()),
}),
Prop::Fg | Prop::Bg | Prop::On | Prop::Bc | Prop::Edge | Prop::Hover => {
if is_gradient(&value) {
PropValue::Gradient(value)
} else if is_theme_token(&value) {
PropValue::Token(value)
} else if let Some(color) = Color::parse(&value) {
PropValue::Color(color)
} else {
return Err(bad());
}
},
Prop::Bold
| Prop::Dim
| Prop::Italic
| Prop::Underline
| Prop::Reverse
| Prop::Strike
| Prop::Trim
| Prop::Bleed
| Prop::Multi
| Prop::Custom
| Prop::Mask
| Prop::Recommended
| Prop::Open
| Prop::Required
| Prop::Submit
| Prop::Cancel
| Prop::Confirm
| Prop::Accent
| Prop::Vertical
| Prop::Focus => PropValue::Bool(true),
_ => PropValue::Str(value),
})
}
fn is_theme_token(value: &str) -> bool {
Theme::is_token(value)
}
fn is_gradient(value: &str) -> bool {
let Some((start, end)) = value.split_once("..") else {
return false;
};
is_color(start) && is_color(end)
}
fn is_color(value: &str) -> bool {
is_theme_token(value) || Color::parse(value).is_some()
}
fn parse_angle(value: &str) -> Option<u16> {
let value = value.trim();
let value = value.strip_suffix("deg").unwrap_or(value);
Some(value.parse::<i32>().ok()?.rem_euclid(360) as u16)
}
fn parse_duration_ms(value: &str) -> Option<u16> {
let value = value.trim();
if let Some(millis) = value.strip_suffix("ms") {
return millis.trim().parse().ok();
}
if let Some(seconds) = value.strip_suffix('s') {
let seconds: f32 = seconds.trim().parse().ok()?;
if !(0.0..=65.0).contains(&seconds) {
return None;
}
return Some((seconds * 1000.0).round() as u16);
}
value.parse().ok()
}
fn display_value(value: &PropValue) -> Str {
match value {
PropValue::Bool(value) => Str::new(if *value { "true" } else { "false" }),
PropValue::U16(value) => Str::from(value.to_string()),
PropValue::F32(value) => Str::from(value.to_string()),
PropValue::I64(value) => Str::from(value.to_string()),
PropValue::Color(Color::Default) => Str::new_static("default"),
PropValue::Color(Color::Indexed(value)) => Str::from(value.to_string()),
PropValue::Color(Color::Rgb(r, g, b)) => Str::from(format!("#{r:02x}{g:02x}{b:02x}")),
PropValue::Token(value) | PropValue::Gradient(value) | PropValue::Str(value) => value.clone(),
PropValue::Easing(value) => Str::new_static(match value {
Easing::Linear => "linear",
Easing::EaseIn => "in",
Easing::EaseOut => "out",
Easing::EaseInOut => "in-out",
}),
PropValue::Dim(Dim::Cells(value)) => Str::from(value.to_string()),
PropValue::Dim(Dim::Pct(value)) => Str::from(format!("{value}%")),
PropValue::Border(value) => Str::new_static(match value {
Border::Square => "square",
Border::Dash => "dash",
Border::Round => "round",
Border::Heavy => "heavy",
Border::Double => "double",
}),
PropValue::Align(value) => Str::new_static(match value {
Align::Start => "start",
Align::Center => "center",
Align::End => "end",
}),
PropValue::VAlign(value) => Str::new_static(match value {
VAlign::Start => "start",
VAlign::Center => "center",
VAlign::End => "end",
VAlign::Stretch => "stretch",
}),
PropValue::Justify(value) => Str::new_static(match value {
Justify::Start => "start",
Justify::Center => "center",
Justify::End => "end",
Justify::Between => "between",
}),
}
}
macro_rules! from_value {
($type:ty, $variant:ident) => {
impl From<$type> for PropValue {
fn from(value: $type) -> Self {
Self::$variant(value)
}
}
};
}
from_value!(Color, Color);
from_value!(bool, Bool);
from_value!(u16, U16);
from_value!(f32, F32);
from_value!(i64, I64);
from_value!(Str, Str);
from_value!(Dim, Dim);
from_value!(Border, Border);
from_value!(Align, Align);
from_value!(VAlign, VAlign);
from_value!(Justify, Justify);
from_value!(Easing, Easing);
impl From<&str> for PropValue {
fn from(value: &str) -> Self {
Self::Str(Str::new(value))
}
}
impl From<String> for PropValue {
fn from(value: String) -> Self {
Self::Str(value.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_values_parse_at_set_time() {
assert_eq!(
Props::new().with(Prop::Fg, "blue").get(Prop::Fg),
Some(&PropValue::Color(Color::Rgb(0, 0, 255)))
);
assert_eq!(
Props::new().with(Prop::Fg, "accent").get(Prop::Fg),
Some(&PropValue::Token(Str::new("accent")))
);
assert_eq!(
Props::new().with(Prop::Title, "x").get(Prop::Title),
Some(&PropValue::Str(Str::new("x")))
);
}
#[test]
fn gradients_and_angles_use_standard_color_properties() {
let props = Props::new()
.with(Prop::Bg, "accent..info")
.with(Prop::Fg, "#000000..#ffffff")
.with(Prop::Angle, "-90deg");
assert_eq!(props.get(Prop::Bg), Some(&PropValue::Gradient(Str::new("accent..info"))));
assert_eq!(props.get(Prop::Fg), Some(&PropValue::Gradient(Str::new("#000000..#ffffff"))));
assert_eq!(props.angle(), 270);
assert!(Props::prop_of("gradient").is_none());
assert!(Props::prop_of("dir").is_none());
}
#[test]
#[should_panic(expected = "nosuch")]
fn invalid_known_value_panics() {
let _ = Props::new().with(Prop::Fg, "nosuch");
}
#[test]
fn invalid_known_value_is_fallible() {
let mut props = Props::new();
assert!(props.try_set(Prop::Fg, PropValue::from("nosuch")).is_err());
}
#[test]
fn values_format_and_customs_round_trip() {
let props = Props::new()
.with(Prop::Gap, 2_u16)
.with_custom("data-x", "1");
assert_eq!(props.get_str(Prop::Gap).as_deref(), Some("2"));
assert_eq!(props.custom("data-x"), Some(&PropValue::Str(Str::new("1"))));
assert_eq!(props.named("data-x"), props.custom("data-x"));
}
#[test]
fn style_resolves_tokens_at_read_time() {
let theme = Theme { accent: Color::Rgb(1, 2, 3), ..Theme::default() };
let props = Props::new().with(Prop::Fg, "accent").with(Prop::Bold, true);
assert_eq!(props.style(&theme).foreground_color(), Color::Rgb(1, 2, 3));
assert_eq!(props.get(Prop::Bold), Some(&PropValue::Bool(true)));
assert!(props.flag(Prop::Bold));
assert!(!Props::new().with(Prop::Bold, false).flag(Prop::Bold));
}
#[test]
fn anim_props_parse_durations_and_easing() {
let mut props = Props::new();
props.set(Prop::Anim, "150ms");
assert_eq!(props.anim(), Some(Duration::from_millis(150)));
props.set(Prop::Anim, "0.4s");
assert_eq!(props.anim(), Some(Duration::from_millis(400)));
props.set(Prop::Anim, "250");
assert_eq!(props.anim(), Some(Duration::from_millis(250)));
props.set(Prop::Spin, "2s");
assert_eq!(props.spin(), Some(Duration::from_millis(2000)));
props.set(Prop::Shimmer, "1.5s");
assert_eq!(props.shimmer(), Some(Duration::from_millis(1500)));
props.set(Prop::Reveal, "500ms");
assert_eq!(props.reveal(), Some(Duration::from_millis(500)));
let bare = Props::new()
.with(Prop::Anim, true)
.with(Prop::Spin, true)
.with(Prop::Shimmer, true)
.with(Prop::Reveal, true);
assert_eq!(bare.anim(), Some(Duration::from_millis(200)));
assert_eq!(bare.spin(), Some(Duration::from_millis(3000)));
assert_eq!(bare.shimmer(), Some(Duration::from_millis(2000)));
assert_eq!(bare.reveal(), Some(Duration::from_millis(250)));
assert_eq!(Props::new().reveal(), None);
assert_eq!(Props::new().anim(), None);
assert_eq!(props.ease(), Easing::EaseOut);
props.set(Prop::Ease, "in-out");
assert_eq!(props.ease(), Easing::EaseInOut);
assert_eq!(props.get_str(Prop::Ease).as_deref(), Some("in-out"));
assert!(
props
.try_set(Prop::Ease, PropValue::from("bouncy"))
.is_err()
);
assert!(props.try_set(Prop::Anim, PropValue::from("fast")).is_err());
assert!(props.try_set(Prop::Spin, PropValue::from("99s")).is_err());
}
#[test]
fn prop_indices_round_trip_through_the_catalog() {
use strum::IntoEnumIterator as _;
for (index, prop) in Prop::iter().enumerate() {
assert_eq!(prop as usize, index, "the catalog diverges from enum order at {prop:?}");
assert_eq!(Prop::try_from_index(index), Ok(prop));
}
}
}