use ratatui::{
layout::Constraint,
widgets::{BorderType, Borders, Padding},
};
use crate::color::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<_>>>()?;
Ok(match nums.len() {
0 => Self::zero(),
1 => Self::uniform(nums[0]),
2 => Self { top: nums[0], bottom: nums[0], left: nums[1], right: nums[1] },
3 => Self { top: nums[0], left: nums[1], right: nums[1], bottom: nums[2] },
n => Self {
top: nums[0],
right: nums[1],
bottom: nums[2 % n],
left: nums[3 % n],
},
})
}
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: BorderStyle,
pub color: Option<Color>,
pub edges: Option<Borders>,
}
impl Default for BorderSpec {
fn default() -> Self {
Self { style: BorderStyle::None, color: None, edges: None }
}
}
impl BorderSpec {
pub fn edges_to_keyword(edges: Borders) -> &'static str {
if edges == Borders::ALL {
return "all";
}
if edges == Borders::NONE {
return "none";
}
let mut parts: Vec<&'static str> = Vec::new();
if edges.contains(Borders::TOP) {
parts.push("top");
}
if edges.contains(Borders::RIGHT) {
parts.push("right");
}
if edges.contains(Borders::BOTTOM) {
parts.push("bottom");
}
if edges.contains(Borders::LEFT) {
parts.push("left");
}
match parts.len() {
0 => "none",
_ => Box::leak(parts.join("|").into_boxed_str()),
}
}
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 self.style == BorderStyle::None {
Borders::NONE
} else {
self.edges.unwrap_or(Borders::ALL)
}
}
pub fn border_type(&self) -> BorderType {
self.style.to_border_type().unwrap_or(BorderType::Plain)
}
pub fn parse_shorthand(s: &str) -> Result<Self> {
let mut style = BorderStyle::None;
let mut color_tokens: Vec<&str> = Vec::new();
for tok in s.split_whitespace() {
if tok.ends_with("px") {
continue;
}
if let Some(parsed) = BorderStyle::parse_keyword(tok) {
style = 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) {
if other.style != BorderStyle::None {
self.style = other.style;
}
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 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))
}
fn split_top_comma(s: &str) -> (&str, &str) {
let mut depth: u32 = 0;
for (i, ch) in s.char_indices() {
match ch {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
',' if depth == 0 => return (&s[..i], &s[i + 1..]),
_ => {}
}
}
(s, "")
}
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) -> BoxEdges;
}
impl IntoBoxEdges for u16 {
fn into_edges(self) -> BoxEdges {
BoxEdges::uniform(self)
}
}
impl IntoBoxEdges for (u16, u16) {
fn into_edges(self) -> BoxEdges {
let (a, b) = self;
BoxEdges { top: a, bottom: a, left: b, right: b }
}
}
impl IntoBoxEdges for (u16, u16, u16, u16) {
fn into_edges(self) -> BoxEdges {
let (top, right, bottom, left) = self;
BoxEdges { top, right, bottom, left }
}
}
impl IntoBoxEdges for &str {
fn into_edges(self) -> BoxEdges {
BoxEdges::parse(self)
.expect("invalid padding/margin shorthand — pass a u16 or tuple for infallible construction")
}
}
impl IntoBoxEdges for BoxEdges {
fn into_edges(self) -> BoxEdges {
self
}
}
pub trait IntoBorderSpec {
fn into_spec(self) -> BorderSpec;
}
impl IntoBorderSpec for BorderStyle {
fn into_spec(self) -> BorderSpec {
BorderSpec { style: self, color: None, edges: None }
}
}
impl<C: Into<Color>> IntoBorderSpec for (BorderStyle, C) {
fn into_spec(self) -> BorderSpec {
let (style, color) = self;
BorderSpec { 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: BorderStyle::Rounded, color: None, edges: None };
let b = BorderSpec { style: BorderStyle::None, color: Some(Color::literal(RC::Blue)), edges: None };
a.merge(&b);
assert_eq!(a.style, BorderStyle::Rounded); assert_eq!(a.color, Some(Color::literal(RC::Blue)));
let mut c = BorderSpec { style: BorderStyle::Double, color: None, edges: None };
c.merge(&BorderSpec::default());
assert_eq!(c.style, 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_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: BoxEdges = 1u16.into_edges();
assert_eq!(e, BoxEdges::uniform(1));
}
#[test]
fn into_box_edges_pair() {
let e: BoxEdges = (0u16, 2u16).into_edges();
assert_eq!((e.top, e.right, e.bottom, e.left), (0, 2, 0, 2));
}
#[test]
fn into_box_edges_quad() {
let e: BoxEdges = (1u16, 2u16, 3u16, 4u16).into_edges();
assert_eq!((e.top, e.right, e.bottom, e.left), (1, 2, 3, 4));
}
#[test]
fn into_box_edges_string_matches_pair() {
let typed = (0u16, 2u16).into_edges();
let from_str: BoxEdges = "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, 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, 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, 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: 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: 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: BorderStyle::Rounded, color: None, edges: Some(Borders::TOP) };
let b = BorderSpec { style: BorderStyle::None, color: None, edges: Some(Borders::BOTTOM) };
a.merge(&b);
assert_eq!(a.style, 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: BorderStyle::Rounded, color: None, edges: Some(Borders::TOP) };
let legacy = BorderSpec { style: 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: BorderStyle::Rounded, color: None, edges: Some(Borders::ALL) };
let b = BorderSpec { style: 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));
}
}
#[cfg(feature = "serde")]
mod serde_impl {
use super::{length_to_css, BorderStyle, BorderSpec, BoxEdges, Length};
use crate::color::Color;
use ratatui::widgets::Borders;
use serde::{de::Error, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
impl<'de> Deserialize<'de> for BoxEdges {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::Number(n) => {
let v = n.as_u64().unwrap_or(0) as u16;
Ok(BoxEdges::uniform(v))
}
Value::String(s) => BoxEdges::parse(&s).map_err(D::Error::custom),
other => Err(D::Error::custom(format!("invalid padding/margin: {other}"))),
}
}
}
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> {
match Value::deserialize(d)? {
Value::Number(n) => Ok(Length::Cells(n.as_u64().unwrap_or(0) as u16)),
Value::String(s) => Length::parse(&s).map_err(D::Error::custom),
other => Err(D::Error::custom(format!("invalid length: {other}"))),
}
}
}
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> {
let s = String::deserialize(d)?;
BorderStyle::parse_keyword(&s)
.ok_or_else(|| D::Error::custom(format!("invalid border style: {s}")))
}
}
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 BorderSpec {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(s) => BorderSpec::parse_shorthand(&s).map_err(D::Error::custom),
Value::Object(map) => {
let style = match map.get("style") {
Some(v) => serde_json::from_value::<BorderStyle>(v.clone())
.map_err(D::Error::custom)?,
None => BorderStyle::None,
};
let color = match map.get("color") {
Some(Value::Null) | None => None,
Some(v) => Some(serde_json::from_value::<Color>(v.clone()).map_err(D::Error::custom)?),
};
let edges = match map.get("edges") {
Some(Value::Null) | None => None,
Some(Value::String(s)) => {
Some(BorderSpec::parse_edges(s).ok_or_else(|| {
D::Error::custom(format!("invalid edges: {s}"))
})?)
}
Some(Value::Number(n)) => {
let bits = n.as_u64().unwrap_or(0) as u8;
Some(Borders::from_bits(bits).unwrap_or(Borders::NONE))
}
Some(other) => {
return Err(D::Error::custom(format!(
"invalid edges: {other}"
)))
}
};
Ok(BorderSpec { style, color, edges })
}
other => Err(D::Error::custom(format!("invalid border: {other}"))),
}
}
}
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()
}
}
}