use ratatui::{
layout::Constraint,
widgets::{BorderType, Borders, Padding},
};
use crate::color::{split_top_comma, Color};
use crate::error::{CssError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct BoxEdges {
pub top: u16,
pub right: u16,
pub bottom: u16,
pub left: u16,
}
impl BoxEdges {
pub const fn uniform(v: u16) -> Self {
Self {
top: v,
right: v,
bottom: v,
left: v,
}
}
pub const fn zero() -> Self {
Self {
top: 0,
right: 0,
bottom: 0,
left: 0,
}
}
pub fn parse(shorthand: &str) -> Result<Self> {
let parts: Vec<&str> = shorthand.split_whitespace().collect();
let nums: Vec<u16> = parts
.iter()
.map(|p| {
p.trim_end_matches("px")
.parse::<u16>()
.map_err(|_| CssError::invalid_length(shorthand))
})
.collect::<Result<Vec<_>>>()?;
match nums.len() {
0 => Ok(Self::zero()),
1 => Ok(Self::uniform(nums[0])),
2 => Ok(Self {
top: nums[0],
bottom: nums[0],
left: nums[1],
right: nums[1],
}),
3 => Ok(Self {
top: nums[0],
left: nums[1],
right: nums[1],
bottom: nums[2],
}),
4 => Ok(Self {
top: nums[0],
right: nums[1],
bottom: nums[2],
left: nums[3],
}),
_ => Err(CssError::invalid_length(format!(
"box shorthand allows at most 4 values, got {}: {shorthand}",
nums.len()
))),
}
}
pub fn to_padding(self) -> Padding {
Padding::new(self.left, self.right, self.top, self.bottom)
}
pub fn shrink(self, area: ratatui::layout::Rect) -> ratatui::layout::Rect {
let x = area.x.saturating_add(self.left);
let y = area.y.saturating_add(self.top);
let width = area
.width
.saturating_sub(self.left.saturating_add(self.right));
let height = area
.height
.saturating_sub(self.top.saturating_add(self.bottom));
ratatui::layout::Rect::new(x, y, width, height)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BorderStyle {
#[default]
None,
Single,
Rounded,
Double,
Thick,
}
impl BorderStyle {
pub fn to_border_type(self) -> Option<BorderType> {
match self {
Self::None => None,
Self::Single => Some(BorderType::Plain),
Self::Rounded => Some(BorderType::Rounded),
Self::Double => Some(BorderType::Double),
Self::Thick => Some(BorderType::Thick),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct BorderSpec {
pub style: BorderStyleValue,
pub color: Option<Color>,
pub edges: Option<Borders>,
}
impl Default for BorderSpec {
fn default() -> Self {
Self {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: None,
edges: None,
}
}
}
impl BorderSpec {
pub fn edges_to_keyword(edges: Borders) -> &'static str {
const EDGES_KW: [&str; 16] = [
"none", "top", "right", "top|right", "bottom", "top|bottom", "right|bottom", "top|right|bottom", "left", "top|left", "right|left", "top|right|left", "bottom|left", "top|bottom|left", "right|bottom|left", "all", ];
let bits = edges.bits() as usize;
if bits >= EDGES_KW.len() {
return "none";
}
EDGES_KW[bits]
}
pub fn parse_edges(s: &str) -> Option<Borders> {
let lower = s.trim().to_ascii_lowercase();
if lower.is_empty() {
return None;
}
let mut acc = Borders::NONE;
for part in lower.split('|') {
let part = part.trim();
acc |= match part {
"all" => Borders::ALL,
"none" => Borders::NONE,
"top" => Borders::TOP,
"right" => Borders::RIGHT,
"bottom" => Borders::BOTTOM,
"left" => Borders::LEFT,
"x" => Borders::LEFT | Borders::RIGHT,
"y" => Borders::TOP | Borders::BOTTOM,
_ => return None,
};
}
Some(acc)
}
pub fn borders(&self) -> Borders {
if matches!(self.style.as_fixed(), Some(BorderStyle::None)) {
Borders::NONE
} else {
self.edges.unwrap_or(Borders::ALL)
}
}
pub fn border_type(&self) -> BorderType {
self.style
.as_fixed()
.and_then(|s| s.to_border_type())
.unwrap_or(BorderType::Plain)
}
pub fn parse_shorthand(s: &str) -> Result<Self> {
let mut style: BorderStyleValue = BorderStyleValue::Fixed(BorderStyle::None);
let mut color_tokens: Vec<&str> = Vec::new();
let lowered = s.to_ascii_lowercase();
let bytes = s.as_bytes();
let mut i = 0;
let mut consumed: Vec<(usize, usize)> = Vec::new();
while i < bytes.len() {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let tok = &s[start..i];
if lowered[start..i].starts_with("var(") {
let mut joined = String::from(tok);
while !joined.ends_with(')') && i < bytes.len() {
let ws_start = i;
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= bytes.len() {
break;
}
let t2_start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
i += 1;
}
joined.push_str(&s[ws_start..i]);
let _ = t2_start; }
let style_already_set =
!matches!(style, BorderStyleValue::Fixed(BorderStyle::None));
if style_already_set {
color_tokens.push(&s[start..i]);
} else {
consumed.push((start, i));
style = BorderStyleValue::parse(&joined)?;
}
} else if tok.ends_with("px") {
consumed.push((start, start + tok.len()));
} else if let Some(parsed) = BorderStyle::parse_keyword(tok) {
consumed.push((start, start + tok.len()));
style = BorderStyleValue::Fixed(parsed);
} else {
color_tokens.push(tok);
}
}
let color = if color_tokens.is_empty() {
None
} else {
Some(Color::parse(&color_tokens.join(" "))?)
};
Ok(Self {
style,
color,
edges: Some(Borders::ALL),
})
}
pub fn merge(&mut self, other: &BorderSpec) {
let other_declares_style =
!matches!(other.style, BorderStyleValue::Fixed(BorderStyle::None));
if other_declares_style {
self.style = other.style.clone();
}
if other.color.is_some() {
self.color = other.color.clone();
}
if let Some(oe) = other.edges {
self.edges = Some(self.edges.unwrap_or(Borders::NONE) | oe);
}
}
}
impl BorderStyle {
pub fn parse_keyword(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"none" | "hidden" => Self::None,
"single" | "solid" | "plain" => Self::Single,
"rounded" => Self::Rounded,
"double" => Self::Double,
"thick" => Self::Thick,
_ => return None,
})
}
pub fn as_keyword(self) -> &'static str {
match self {
Self::None => "none",
Self::Single => "single",
Self::Rounded => "rounded",
Self::Double => "double",
Self::Thick => "thick",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BoxEdgesValue {
Edges(BoxEdges),
Var {
name: String,
fallback: Option<Box<BoxEdgesValue>>,
},
}
impl BoxEdgesValue {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let lower = s.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("var(") {
let inner = rest.strip_suffix(')').unwrap_or(rest);
let (name_part, fallback_part) = split_top_comma(inner);
let name = name_part.trim().trim_start_matches('-').trim().to_string();
if name.is_empty() {
return Err(CssError::invalid_length(format!(
"var(): empty name in {s}"
)));
}
let fallback = match fallback_part.trim() {
"" => None,
expr => Some(Box::new(Self::parse(expr)?)),
};
return Ok(Self::Var { name, fallback });
}
BoxEdges::parse(s).map(Self::Edges)
}
pub fn is_var(&self) -> bool {
matches!(self, Self::Var { .. })
}
pub fn var(name: impl Into<String>) -> Self {
Self::Var {
name: name.into(),
fallback: None,
}
}
}
impl From<BoxEdges> for BoxEdgesValue {
fn from(e: BoxEdges) -> Self {
Self::Edges(e)
}
}
impl std::fmt::Display for BoxEdgesValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Edges(e) => {
let e = *e;
if e.top == e.right && e.right == e.bottom && e.bottom == e.left {
write!(f, "{}", e.top)
} else {
write!(f, "{} {} {} {}", e.top, e.right, e.bottom, e.left)
}
}
Self::Var { name, fallback } => match fallback {
Some(fb) => write!(f, "var(--{name}, {fb})"),
None => write!(f, "var(--{name})"),
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum BorderStyleValue {
Fixed(BorderStyle),
Var {
name: String,
fallback: Option<Box<BorderStyleValue>>,
},
}
impl BorderStyleValue {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
let lower = s.to_ascii_lowercase();
if let Some(rest) = lower.strip_prefix("var(") {
let inner = rest.strip_suffix(')').unwrap_or(rest);
let (name_part, fallback_part) = split_top_comma(inner);
let name = name_part.trim().trim_start_matches('-').trim().to_string();
if name.is_empty() {
return Err(CssError::invalid_length(format!(
"var(): empty name in {s}"
)));
}
let fallback = match fallback_part.trim() {
"" => None,
expr => Some(Box::new(Self::parse(expr)?)),
};
return Ok(Self::Var { name, fallback });
}
match BorderStyle::parse_keyword(s) {
Some(b) => Ok(Self::Fixed(b)),
None => Err(CssError::invalid_length(format!("border-style: {s}"))),
}
}
pub fn is_var(&self) -> bool {
matches!(self, Self::Var { .. })
}
pub fn as_fixed(&self) -> Option<BorderStyle> {
match self {
Self::Fixed(b) => Some(*b),
Self::Var { .. } => None,
}
}
pub fn var(name: impl Into<String>) -> Self {
Self::Var {
name: name.into(),
fallback: None,
}
}
}
impl From<BorderStyle> for BorderStyleValue {
fn from(b: BorderStyle) -> Self {
Self::Fixed(b)
}
}
impl Default for BorderStyleValue {
fn default() -> Self {
Self::Fixed(BorderStyle::None)
}
}
impl std::fmt::Display for BorderStyleValue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fixed(b) => f.write_str(b.as_keyword()),
Self::Var { name, fallback } => match fallback {
Some(fb) => write!(f, "var(--{name}, {fb})"),
None => write!(f, "var(--{name})"),
},
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Length {
Auto,
Cells(u16),
Percent(u16),
Min(u16),
Max(u16),
Var {
name: String,
fallback: Option<Box<Length>>,
},
}
impl Length {
pub fn parse(s: &str) -> Result<Self> {
let s = s.trim();
if let Some(inner) = s
.strip_prefix("var(")
.or_else(|| s.strip_prefix("VAR("))
.or_else(|| s.strip_prefix("Var("))
{
let inner = inner.strip_suffix(')').unwrap_or(inner);
let (name_part, fallback_part) = split_top_comma(inner);
let name = name_part.trim().trim_start_matches('-').trim().to_string();
if name.is_empty() {
return Err(CssError::invalid_length(format!(
"var(): empty name in {s}"
)));
}
let fallback = match fallback_part.trim() {
"" => None,
expr => Some(Box::new(Self::parse(expr)?)),
};
return Ok(Self::Var { name, fallback });
}
if s.eq_ignore_ascii_case("auto") || s.is_empty() {
return Ok(Self::Auto);
}
if let Some(rest) = s.strip_prefix("min(").and_then(|r| r.strip_suffix(')')) {
return Ok(Self::Min(parse_cells(rest)?));
}
if let Some(rest) = s.strip_prefix("max(").and_then(|r| r.strip_suffix(')')) {
return Ok(Self::Max(parse_cells(rest)?));
}
if let Some(rest) = s.strip_suffix('%') {
return Ok(Self::Percent(
rest.parse().map_err(|_| CssError::invalid_length(s))?,
));
}
Ok(Self::Cells(parse_cells(s)?))
}
pub fn to_constraint(&self) -> Constraint {
match self {
Self::Auto => Constraint::Min(0),
Self::Cells(n) => Constraint::Length(*n),
Self::Percent(p) => Constraint::Percentage(*p),
Self::Min(n) => Constraint::Min(*n),
Self::Max(n) => Constraint::Max(*n),
Self::Var {
fallback: Some(fb), ..
} => fb.to_constraint(),
Self::Var { fallback: None, .. } => Constraint::Min(0),
}
}
}
fn parse_cells(s: &str) -> Result<u16> {
s.trim_end_matches("px")
.trim()
.parse::<u16>()
.map_err(|_| CssError::invalid_length(s))
}
#[cfg(feature = "serde")]
fn length_to_css(length: &Length) -> String {
match length {
Length::Auto => "auto".to_string(),
Length::Cells(n) => format!("{n}px"),
Length::Percent(p) => format!("{p}%"),
Length::Min(n) => format!("min({n})"),
Length::Max(n) => format!("max({n})"),
Length::Var {
name,
fallback: None,
} => format!("var(--{name})"),
Length::Var {
name,
fallback: Some(fb),
} => {
format!("var(--{name}, {})", length_to_css(fb))
}
}
}
pub trait IntoBoxEdges {
fn into_edges(self) -> BoxEdgesValue;
}
impl IntoBoxEdges for u16 {
fn into_edges(self) -> BoxEdgesValue {
BoxEdgesValue::Edges(BoxEdges::uniform(self))
}
}
impl IntoBoxEdges for (u16, u16) {
fn into_edges(self) -> BoxEdgesValue {
let (a, b) = self;
BoxEdgesValue::Edges(BoxEdges {
top: a,
bottom: a,
left: b,
right: b,
})
}
}
impl IntoBoxEdges for (u16, u16, u16, u16) {
fn into_edges(self) -> BoxEdgesValue {
let (top, right, bottom, left) = self;
BoxEdgesValue::Edges(BoxEdges {
top,
right,
bottom,
left,
})
}
}
impl IntoBoxEdges for &str {
fn into_edges(self) -> BoxEdgesValue {
BoxEdgesValue::parse(self).expect(
"invalid padding/margin shorthand — pass a u16 or tuple for infallible construction",
)
}
}
impl IntoBoxEdges for BoxEdges {
fn into_edges(self) -> BoxEdgesValue {
BoxEdgesValue::Edges(self)
}
}
impl IntoBoxEdges for BoxEdgesValue {
fn into_edges(self) -> BoxEdgesValue {
self
}
}
pub trait IntoBorderSpec {
fn into_spec(self) -> BorderSpec;
}
impl IntoBorderSpec for BorderStyle {
fn into_spec(self) -> BorderSpec {
BorderSpec {
style: BorderStyleValue::Fixed(self),
color: None,
edges: None,
}
}
}
impl<C: Into<Color>> IntoBorderSpec for (BorderStyle, C) {
fn into_spec(self) -> BorderSpec {
let (style, color) = self;
BorderSpec {
style: BorderStyleValue::Fixed(style),
color: Some(color.into()),
edges: None,
}
}
}
impl IntoBorderSpec for &str {
fn into_spec(self) -> BorderSpec {
BorderSpec::parse_shorthand(self)
.expect("invalid border shorthand — pass a BorderStyle / (BorderStyle, color) for infallible construction")
}
}
impl IntoBorderSpec for BorderSpec {
fn into_spec(self) -> BorderSpec {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn border_spec_merge_keeps_declared_subfields() {
use ratatui::style::Color as RC;
let mut a = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Rounded),
color: None,
edges: None,
};
let b = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: Some(Color::literal(RC::Blue)),
edges: None,
};
a.merge(&b);
assert_eq!(a.style, BorderStyleValue::Fixed(BorderStyle::Rounded)); assert_eq!(a.color, Some(Color::literal(RC::Blue)));
let mut c = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Double),
color: None,
edges: None,
};
c.merge(&BorderSpec::default());
assert_eq!(c.style, BorderStyleValue::Fixed(BorderStyle::Double));
}
#[test]
fn edges_shorthand() {
assert_eq!(BoxEdges::parse("1").unwrap(), BoxEdges::uniform(1));
let e = BoxEdges::parse("1 2").unwrap();
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 1, 2));
let e = BoxEdges::parse("1 2 3 4").unwrap();
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 3, 4));
}
#[test]
fn edges_shorthand_rejects_more_than_four() {
assert!(BoxEdges::parse("1 2 3 4 5").is_err());
assert!(BoxEdges::parse("1 2 3 4 5 6").is_err());
assert!(BoxEdges::parse("1 2 3 4").is_ok());
}
#[test]
fn edges_shrink() {
let area = ratatui::layout::Rect::new(0, 0, 10, 10);
let inner = BoxEdges::uniform(1).shrink(area);
assert_eq!((inner.x, inner.y, inner.width, inner.height), (1, 1, 8, 8));
}
#[test]
fn length_parse() {
assert_eq!(Length::parse("auto").unwrap(), Length::Auto);
assert_eq!(Length::parse("10px").unwrap(), Length::Cells(10));
assert_eq!(Length::parse("50%").unwrap(), Length::Percent(50));
assert_eq!(Length::parse("min(3)").unwrap(), Length::Min(3));
}
#[test]
fn length_var_parse() {
assert_eq!(
Length::parse("var(--w)").unwrap(),
Length::Var {
name: "w".into(),
fallback: None
}
);
assert_eq!(Length::parse("10").unwrap(), Length::Cells(10));
assert_eq!(Length::parse("50%").unwrap(), Length::Percent(50));
assert_eq!(
Length::parse("var(--w, 10)").unwrap(),
Length::Var {
name: "w".into(),
fallback: Some(Box::new(Length::Cells(10)))
}
);
assert_eq!(
Length::parse("var(--w, 50%)").unwrap(),
Length::Var {
name: "w".into(),
fallback: Some(Box::new(Length::Percent(50)))
}
);
assert!(Length::parse("var(--)").is_err());
}
#[test]
fn length_var_degrades_to_min_zero() {
assert_eq!(
Length::Var {
name: "x".into(),
fallback: None
}
.to_constraint(),
Constraint::Min(0)
);
assert_eq!(
Length::Var {
name: "x".into(),
fallback: Some(Box::new(Length::Cells(7)))
}
.to_constraint(),
Constraint::Length(7)
);
}
#[test]
fn into_box_edges_uniform() {
let e: BoxEdgesValue = 1u16.into_edges();
assert_eq!(e, BoxEdgesValue::Edges(BoxEdges::uniform(1)));
}
#[test]
fn into_box_edges_pair() {
let e: BoxEdgesValue = (0u16, 2u16).into_edges();
match e {
BoxEdgesValue::Edges(e) => {
assert_eq!((e.top, e.right, e.bottom, e.left), (0, 2, 0, 2));
}
other => panic!("expected Edges, got {other:?}"),
}
}
#[test]
fn into_box_edges_quad() {
let e: BoxEdgesValue = (1u16, 2u16, 3u16, 4u16).into_edges();
match e {
BoxEdgesValue::Edges(e) => {
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 3, 4));
}
other => panic!("expected Edges, got {other:?}"),
}
}
#[test]
fn into_box_edges_string_matches_pair() {
let typed = (0u16, 2u16).into_edges();
let from_str: BoxEdgesValue = "0 2".into_edges();
assert_eq!(typed, from_str);
}
#[test]
fn into_border_spec_style_only() {
let spec = BorderStyle::Rounded.into_spec();
assert_eq!(spec.style, BorderStyleValue::Fixed(BorderStyle::Rounded));
assert_eq!(spec.color, None);
}
#[test]
fn into_border_spec_with_color() {
use ratatui::style::Color as RC;
let spec = (BorderStyle::Double, "#ff0000").into_spec();
assert_eq!(spec.style, BorderStyleValue::Fixed(BorderStyle::Double));
assert_eq!(spec.color, Some(Color::literal(RC::Rgb(255, 0, 0))));
}
#[test]
fn into_border_spec_string_matches() {
let typed = BorderStyle::Single.into_spec();
let from_str: BorderSpec = "single".into_spec();
assert_eq!(typed.style, from_str.style);
assert_eq!(typed.color, from_str.color);
}
#[test]
fn border_full_shorthand_all_edges() {
let spec = BorderSpec::parse_shorthand("rounded").unwrap();
assert_eq!(spec.style, BorderStyleValue::Fixed(BorderStyle::Rounded));
assert_eq!(spec.edges, Some(Borders::ALL));
assert_eq!(spec.borders(), Borders::ALL);
}
#[test]
fn border_style_only_legacy_all() {
let spec = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Rounded),
color: None,
edges: None,
};
assert_eq!(spec.borders(), Borders::ALL);
}
#[test]
fn border_none_style_draws_nothing_even_with_edges() {
let spec = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: None,
edges: Some(Borders::BOTTOM),
};
assert_eq!(spec.borders(), Borders::NONE);
}
#[test]
fn per_edge_merge_accumulates() {
let mut a = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Rounded),
color: None,
edges: Some(Borders::TOP),
};
let b = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: None,
edges: Some(Borders::BOTTOM),
};
a.merge(&b);
assert_eq!(a.style, BorderStyleValue::Fixed(BorderStyle::Rounded)); assert_eq!(a.edges, Some(Borders::TOP | Borders::BOTTOM));
assert_eq!(a.borders(), Borders::TOP | Borders::BOTTOM);
}
#[test]
fn per_edge_merge_legacy_none_edges_not_touched() {
let mut a = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Rounded),
color: None,
edges: Some(Borders::TOP),
};
let legacy = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: None,
edges: None,
};
a.merge(&legacy);
assert_eq!(a.edges, Some(Borders::TOP)); }
#[test]
fn per_edge_full_shorthand_then_edge_widens() {
let mut a = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::Rounded),
color: None,
edges: Some(Borders::ALL),
};
let b = BorderSpec {
style: BorderStyleValue::Fixed(BorderStyle::None),
color: None,
edges: Some(Borders::BOTTOM),
};
a.merge(&b);
assert_eq!(a.edges, Some(Borders::ALL));
}
#[test]
fn edges_keyword_roundtrip() {
for (keyword, edges) in [
("all", Borders::ALL),
("none", Borders::NONE),
("top", Borders::TOP),
("bottom", Borders::BOTTOM),
("top|bottom", Borders::TOP | Borders::BOTTOM),
("right|left", Borders::LEFT | Borders::RIGHT),
] {
assert_eq!(
BorderSpec::parse_edges(keyword),
Some(edges),
"parse {keyword}"
);
assert_eq!(
BorderSpec::edges_to_keyword(edges),
keyword,
"emit {keyword}"
);
}
assert_eq!(
BorderSpec::parse_edges("left|right"),
Some(Borders::LEFT | Borders::RIGHT)
);
assert_eq!(
BorderSpec::parse_edges("x"),
Some(Borders::LEFT | Borders::RIGHT)
);
assert_eq!(
BorderSpec::parse_edges("y"),
Some(Borders::TOP | Borders::BOTTOM)
);
}
#[test]
fn edges_to_keyword_is_leak_free_and_covers_all_16() {
let combos: [(Borders, &str); 16] = [
(Borders::NONE, "none"),
(Borders::TOP, "top"),
(Borders::RIGHT, "right"),
(Borders::TOP | Borders::RIGHT, "top|right"),
(Borders::BOTTOM, "bottom"),
(Borders::TOP | Borders::BOTTOM, "top|bottom"),
(Borders::RIGHT | Borders::BOTTOM, "right|bottom"),
(
Borders::TOP | Borders::RIGHT | Borders::BOTTOM,
"top|right|bottom",
),
(Borders::LEFT, "left"),
(Borders::TOP | Borders::LEFT, "top|left"),
(Borders::RIGHT | Borders::LEFT, "right|left"),
(
Borders::TOP | Borders::RIGHT | Borders::LEFT,
"top|right|left",
),
(Borders::BOTTOM | Borders::LEFT, "bottom|left"),
(
Borders::TOP | Borders::BOTTOM | Borders::LEFT,
"top|bottom|left",
),
(
Borders::RIGHT | Borders::BOTTOM | Borders::LEFT,
"right|bottom|left",
),
(Borders::ALL, "all"),
];
for (edges, expected) in combos {
let kw = BorderSpec::edges_to_keyword(edges);
assert_eq!(kw, expected, "bits {:#06b}", edges.bits());
assert_eq!(BorderSpec::parse_edges(kw), Some(edges), "roundtrip {kw}");
}
}
#[test]
fn box_edges_value_parse_var_no_fallback() {
assert_eq!(
BoxEdgesValue::parse("var(--pad)").unwrap(),
BoxEdgesValue::Var {
name: "pad".into(),
fallback: None,
}
);
assert_eq!(
BoxEdgesValue::parse("VAR(--pad)").unwrap(),
BoxEdgesValue::Var {
name: "pad".into(),
fallback: None,
}
);
}
#[test]
fn box_edges_value_parse_concrete() {
let e = BoxEdgesValue::parse("1 2").unwrap();
match e {
BoxEdgesValue::Edges(e) => {
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 1, 2));
}
other => panic!("expected Edges, got {other:?}"),
}
}
#[test]
fn box_edges_value_parse_var_with_fallback() {
let v = BoxEdgesValue::parse("var(--pad, 1)").unwrap();
match v {
BoxEdgesValue::Var {
name,
fallback: Some(fb),
} => {
assert_eq!(name, "pad");
assert_eq!(*fb, BoxEdgesValue::Edges(BoxEdges::uniform(1)));
}
other => panic!("expected Var with fallback, got {other:?}"),
}
let v = BoxEdgesValue::parse("var(--pad, 1 2 3 4)").unwrap();
match v {
BoxEdgesValue::Var { fallback: Some(fb), .. } => match *fb {
BoxEdgesValue::Edges(e) => {
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 3, 4));
}
other => panic!("expected Edges fallback, got {other:?}"),
},
other => panic!("expected Var, got {other:?}"),
}
}
#[test]
fn box_edges_value_empty_name_errors() {
assert!(BoxEdgesValue::parse("var(--)").is_err());
}
#[test]
fn box_edges_value_display_roundtrip() {
let v = BoxEdgesValue::Edges(BoxEdges::uniform(3));
assert_eq!(v.to_string(), "3");
let v = BoxEdgesValue::Edges(BoxEdges {
top: 1,
right: 2,
bottom: 3,
left: 4,
});
assert_eq!(v.to_string(), "1 2 3 4");
let v = BoxEdgesValue::var("pad");
assert_eq!(v.to_string(), "var(--pad)");
let v = BoxEdgesValue::Var {
name: "pad".into(),
fallback: Some(Box::new(BoxEdgesValue::Edges(BoxEdges::uniform(1)))),
};
assert_eq!(v.to_string(), "var(--pad, 1)");
}
#[test]
fn border_style_value_parse_var() {
assert_eq!(
BorderStyleValue::parse("var(--bs)").unwrap(),
BorderStyleValue::Var {
name: "bs".into(),
fallback: None,
}
);
}
#[test]
fn border_style_value_parse_keyword() {
assert_eq!(
BorderStyleValue::parse("rounded").unwrap(),
BorderStyleValue::Fixed(BorderStyle::Rounded)
);
assert_eq!(
BorderStyleValue::parse("none").unwrap(),
BorderStyleValue::Fixed(BorderStyle::None)
);
}
#[test]
fn border_style_value_garbage_errors() {
assert!(BorderStyleValue::parse("banana").is_err());
}
#[test]
fn border_style_value_parse_var_with_fallback() {
let v = BorderStyleValue::parse("var(--bs, rounded)").unwrap();
match v {
BorderStyleValue::Var {
name,
fallback: Some(fb),
} => {
assert_eq!(name, "bs");
assert_eq!(*fb, BorderStyleValue::Fixed(BorderStyle::Rounded));
}
other => panic!("expected Var with fallback, got {other:?}"),
}
}
#[test]
fn border_style_value_display_roundtrip() {
assert_eq!(
BorderStyleValue::Fixed(BorderStyle::Rounded).to_string(),
"rounded"
);
assert_eq!(BorderStyleValue::var("bs").to_string(), "var(--bs)");
}
#[test]
fn border_shorthand_accepts_var_style_component() {
let spec = BorderSpec::parse_shorthand("var(--bs)").unwrap();
assert_eq!(spec.style, BorderStyleValue::var("bs"));
assert_eq!(spec.edges, Some(Borders::ALL));
let spec = BorderSpec::parse_shorthand("var(--bs) #f00").unwrap();
assert_eq!(spec.style, BorderStyleValue::var("bs"));
use ratatui::style::Color as RC;
assert_eq!(spec.color, Some(Color::literal(RC::Rgb(0xff, 0, 0))));
}
#[test]
fn border_shorthand_var_with_fallback_in_style() {
let spec = BorderSpec::parse_shorthand("var(--bs, rounded) #f00").unwrap();
assert_eq!(
spec.style,
BorderStyleValue::Var {
name: "bs".into(),
fallback: Some(Box::new(BorderStyleValue::Fixed(BorderStyle::Rounded))),
}
);
}
#[cfg(feature = "serde")]
#[test]
fn box_edges_value_serde_roundtrip() {
let v = BoxEdgesValue::Edges(BoxEdges::uniform(2));
let json = serde_json::to_string(&v).unwrap();
let back: BoxEdgesValue = serde_json::from_str(&json).unwrap();
assert_eq!(back, v);
let v = BoxEdgesValue::var("pad");
let json = serde_json::to_string(&v).unwrap();
assert!(json.contains("var(--pad)"), "serialize var: {json}");
let back: BoxEdgesValue = serde_json::from_str(&json).unwrap();
assert_eq!(back, v);
}
#[cfg(feature = "serde")]
#[test]
fn border_style_value_serde_roundtrip() {
let v = BorderStyleValue::Fixed(BorderStyle::Rounded);
let json = serde_json::to_string(&v).unwrap();
let back: BorderStyleValue = serde_json::from_str(&json).unwrap();
assert_eq!(back, v);
let v = BorderStyleValue::var("bs");
let json = serde_json::to_string(&v).unwrap();
assert!(json.contains("var(--bs)"), "serialize var: {json}");
let back: BorderStyleValue = serde_json::from_str(&json).unwrap();
assert_eq!(back, v);
}
}
#[cfg(feature = "serde")]
mod serde_impl {
use super::{
length_to_css, BorderSpec, BorderStyle, BorderStyleValue, BoxEdges, BoxEdgesValue, Length,
};
use crate::color::Color;
use ratatui::widgets::Borders;
use serde::{
de::{self, MapAccess, Visitor},
Deserialize, Deserializer, Serialize, Serializer,
};
use std::fmt;
impl<'de> Deserialize<'de> for BoxEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BoxEdgesVisitor;
impl<'de> Visitor<'de> for BoxEdgesVisitor {
type Value = BoxEdges;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a CSS box shorthand (number or string)")
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<BoxEdges, E> {
Ok(BoxEdges::uniform(v.max(0) as u16))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<BoxEdges, E> {
Ok(BoxEdges::uniform(v as u16))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<BoxEdges, E> {
Ok(BoxEdges::uniform(v.max(0.0) as u16))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<BoxEdges, E> {
BoxEdges::parse(v).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<BoxEdges, E> {
BoxEdges::parse(&v).map_err(E::custom)
}
}
d.deserialize_any(BoxEdgesVisitor)
}
}
impl Serialize for BoxEdges {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
if self.top == self.right && self.right == self.bottom && self.bottom == self.left {
s.serialize_u64(self.top as u64)
} else {
s.serialize_str(&format!(
"{} {} {} {}",
self.top, self.right, self.bottom, self.left
))
}
}
}
impl<'de> Deserialize<'de> for Length {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct LengthVisitor;
impl<'de> Visitor<'de> for LengthVisitor {
type Value = Length;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a CSS length (number or string)")
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Length, E> {
Ok(Length::Cells(v.max(0) as u16))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Length, E> {
Ok(Length::Cells(v as u16))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Length, E> {
Ok(Length::Cells(v.max(0.0) as u16))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Length, E> {
Length::parse(v).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Length, E> {
Length::parse(&v).map_err(E::custom)
}
}
d.deserialize_any(LengthVisitor)
}
}
impl Serialize for Length {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
Length::Auto => s.serialize_str("auto"),
Length::Cells(n) => s.serialize_str(&format!("{n}px")),
Length::Percent(p) => s.serialize_str(&format!("{p}%")),
Length::Min(n) => s.serialize_str(&format!("min({n})")),
Length::Max(n) => s.serialize_str(&format!("max({n})")),
Length::Var {
name,
fallback: None,
} => s.serialize_str(&format!("var(--{name})")),
Length::Var {
name,
fallback: Some(fb),
} => s.serialize_str(&format!("var(--{name}, {})", length_to_css(fb))),
}
}
}
impl<'de> Deserialize<'de> for BorderStyle {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BorderStyleVisitor;
impl<'de> Visitor<'de> for BorderStyleVisitor {
type Value = BorderStyle;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a border style keyword")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<BorderStyle, E> {
BorderStyle::parse_keyword(v)
.ok_or_else(|| E::custom(format!("invalid border style: {v}")))
}
fn visit_string<E: de::Error>(self, v: String) -> Result<BorderStyle, E> {
BorderStyle::parse_keyword(&v)
.ok_or_else(|| E::custom(format!("invalid border style: {v}")))
}
}
d.deserialize_str(BorderStyleVisitor)
}
}
impl Serialize for BorderStyle {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_keyword())
}
}
impl<'de> Deserialize<'de> for BoxEdgesValue {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BoxEdgesValueVisitor;
impl<'de> Visitor<'de> for BoxEdgesValueVisitor {
type Value = BoxEdgesValue;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a CSS box shorthand or var() string (number or string)")
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<BoxEdgesValue, E> {
Ok(BoxEdgesValue::Edges(BoxEdges::uniform(v.max(0) as u16)))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<BoxEdgesValue, E> {
Ok(BoxEdgesValue::Edges(BoxEdges::uniform(v as u16)))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<BoxEdgesValue, E> {
Ok(BoxEdgesValue::Edges(BoxEdges::uniform(v.max(0.0) as u16)))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<BoxEdgesValue, E> {
BoxEdgesValue::parse(v).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<BoxEdgesValue, E> {
BoxEdgesValue::parse(&v).map_err(E::custom)
}
}
d.deserialize_any(BoxEdgesValueVisitor)
}
}
impl Serialize for BoxEdgesValue {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self {
BoxEdgesValue::Edges(e) => {
if e.top == e.right && e.right == e.bottom && e.bottom == e.left {
s.serialize_u64(e.top as u64)
} else {
s.serialize_str(&format!(
"{} {} {} {}",
e.top, e.right, e.bottom, e.left
))
}
}
BoxEdgesValue::Var { .. } => s.serialize_str(&self.to_string()),
}
}
}
impl<'de> Deserialize<'de> for BorderStyleValue {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BorderStyleValueVisitor;
impl<'de> Visitor<'de> for BorderStyleValueVisitor {
type Value = BorderStyleValue;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a border style keyword or var() string")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<BorderStyleValue, E> {
BorderStyleValue::parse(v).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<BorderStyleValue, E> {
BorderStyleValue::parse(&v).map_err(E::custom)
}
}
d.deserialize_str(BorderStyleValueVisitor)
}
}
impl Serialize for BorderStyleValue {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
enum EdgesInput {
None,
Some(Borders),
}
impl<'de> Deserialize<'de> for EdgesInput {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct EdgesVisitor;
impl<'de> Visitor<'de> for EdgesVisitor {
type Value = EdgesInput;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("an edges keyword string or a bit integer")
}
fn visit_unit<E: de::Error>(self) -> Result<EdgesInput, E> {
Ok(EdgesInput::None)
}
fn visit_none<E: de::Error>(self) -> Result<EdgesInput, E> {
Ok(EdgesInput::None)
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<EdgesInput, E> {
let bits = v as u8;
Ok(EdgesInput::Some(
Borders::from_bits(bits).unwrap_or(Borders::NONE),
))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<EdgesInput, E> {
let bits = v as u8;
Ok(EdgesInput::Some(
Borders::from_bits(bits).unwrap_or(Borders::NONE),
))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<EdgesInput, E> {
let bits = v as u8;
Ok(EdgesInput::Some(
Borders::from_bits(bits).unwrap_or(Borders::NONE),
))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<EdgesInput, E> {
BorderSpec::parse_edges(v)
.map(EdgesInput::Some)
.ok_or_else(|| E::custom(format!("invalid edges: {v}")))
}
fn visit_string<E: de::Error>(self, v: String) -> Result<EdgesInput, E> {
BorderSpec::parse_edges(&v)
.map(EdgesInput::Some)
.ok_or_else(|| E::custom(format!("invalid edges: {v}")))
}
}
d.deserialize_any(EdgesVisitor)
}
}
enum ColorInput {
None,
Some(Color),
}
impl<'de> Deserialize<'de> for ColorInput {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct ColorInputVisitor;
impl<'de> Visitor<'de> for ColorInputVisitor {
type Value = ColorInput;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a CSS color string or null")
}
fn visit_unit<E: de::Error>(self) -> Result<ColorInput, E> {
Ok(ColorInput::None)
}
fn visit_none<E: de::Error>(self) -> Result<ColorInput, E> {
Ok(ColorInput::None)
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<ColorInput, E> {
Color::parse(v).map(ColorInput::Some).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<ColorInput, E> {
Color::parse(&v).map(ColorInput::Some).map_err(E::custom)
}
}
d.deserialize_any(ColorInputVisitor)
}
}
impl<'de> Deserialize<'de> for BorderSpec {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct BorderSpecVisitor;
impl<'de> Visitor<'de> for BorderSpecVisitor {
type Value = BorderSpec;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a border shorthand string or a border object")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<BorderSpec, E> {
BorderSpec::parse_shorthand(v).map_err(E::custom)
}
fn visit_string<E: de::Error>(self, v: String) -> Result<BorderSpec, E> {
BorderSpec::parse_shorthand(&v).map_err(E::custom)
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BorderSpec, A::Error> {
let mut style: Option<BorderStyleValue> = None;
let mut color: Option<Color> = None;
let mut edges: Option<Borders> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"style" => {
style = Some(map.next_value()?);
}
"color" => match map.next_value::<ColorInput>()? {
ColorInput::Some(c) => color = Some(c),
ColorInput::None => {}
},
"edges" => match map.next_value::<EdgesInput>()? {
EdgesInput::Some(e) => edges = Some(e),
EdgesInput::None => {}
},
_ => {
let _: de::IgnoredAny = map.next_value()?;
}
}
}
Ok(BorderSpec {
style: style.unwrap_or_default(),
color,
edges,
})
}
}
d.deserialize_any(BorderSpecVisitor)
}
}
impl Serialize for BorderSpec {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("BorderSpec", 3)?;
st.serialize_field("style", &self.style)?;
st.serialize_field("color", &self.color)?;
match self.edges {
None => st.serialize_field("edges", &None::<&str>)?,
Some(e) => st.serialize_field("edges", BorderSpec::edges_to_keyword(e))?,
}
st.end()
}
}
}