use cssparser::{Parser, Token, match_ignore_ascii_case};
use std::{fmt, sync::Arc};
use crate::layout::style::{
Animatable, BackgroundImage, CssSyntaxKind, CssToken, FromCss, MakeComputed, ParseResult, ToCss,
properties::write_css_string, tw::TailwindPropertyParser, unexpected_token,
};
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub enum ContentValue {
#[default]
Normal,
None,
Items(Box<[ContentItem]>),
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum ContentItem {
Text(Arc<str>),
Image(Box<BackgroundImage>),
Attr(AttrRef),
}
#[derive(Debug, Clone, PartialEq)]
pub struct AttrRef {
pub name: Arc<str>,
pub fallback: Arc<str>,
}
impl MakeComputed for ContentValue {
fn make_computed(&mut self, sizing: &crate::rendering::Sizing) {
if let ContentValue::Items(items) = self {
for item in items.iter_mut() {
if let ContentItem::Image(image) = item {
image.as_mut().make_computed(sizing);
}
}
}
}
}
impl Animatable for ContentValue {}
impl TailwindPropertyParser for ContentValue {
fn parse_tw(token: &str) -> Option<Self> {
match_ignore_ascii_case! {token,
"none" => Some(ContentValue::None),
"normal" => Some(ContentValue::Normal),
_ => None,
}
}
}
impl<'i> FromCss<'i> for ContentValue {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
if input
.try_parse(|input| input.expect_ident_matching("none"))
.is_ok()
{
return Ok(ContentValue::None);
}
if input
.try_parse(|input| input.expect_ident_matching("normal"))
.is_ok()
{
return Ok(ContentValue::Normal);
}
let mut items = Vec::new();
while !input.is_exhausted() {
items.push(ContentItem::from_css(input)?);
}
if items.is_empty() {
let location = input.current_source_location();
return Err(unexpected_token!(Self, location, &Token::WhiteSpace("")));
}
Ok(ContentValue::Items(items.into_boxed_slice()))
}
const VALID_TOKENS: &'static [CssToken] = &[
CssToken::Keyword("none"),
CssToken::Keyword("normal"),
CssToken::Syntax(CssSyntaxKind::String),
CssToken::Syntax(CssSyntaxKind::Image),
];
}
impl<'i> FromCss<'i> for ContentItem {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
let start = input.state();
if let Ok(image) = input.try_parse(BackgroundImage::from_css) {
if !matches!(image, BackgroundImage::None) {
return Ok(ContentItem::Image(Box::new(image)));
}
input.reset(&start);
}
let location = input.current_source_location();
let token = input.next()?.clone();
match token {
Token::QuotedString(value) => Ok(ContentItem::Text(value.as_ref().into())),
Token::Function(ref name) if name.eq_ignore_ascii_case("attr") => input
.parse_nested_block(AttrRef::from_css)
.map(ContentItem::Attr),
other => Err(unexpected_token!(Self, location, &other)),
}
}
const VALID_TOKENS: &'static [CssToken] = &[
CssToken::Syntax(CssSyntaxKind::String),
CssToken::Syntax(CssSyntaxKind::Image),
];
}
impl<'i> FromCss<'i> for AttrRef {
fn from_css(input: &mut Parser<'i, '_>) -> ParseResult<'i, Self> {
let name: Arc<str> = input.expect_ident()?.as_ref().into();
let fallback: Arc<str> = if input.try_parse(Parser::expect_comma).is_ok() {
input.expect_string()?.as_ref().into()
} else {
"".into()
};
Ok(Self { name, fallback })
}
const VALID_TOKENS: &'static [CssToken] = &[CssToken::Syntax(CssSyntaxKind::Ident)];
}
impl ToCss for ContentValue {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
ContentValue::Normal => dest.write_str("normal"),
ContentValue::None => dest.write_str("none"),
ContentValue::Items(items) => {
for (i, item) in items.iter().enumerate() {
if i > 0 {
dest.write_char(' ')?;
}
item.to_css(dest)?;
}
Ok(())
}
}
}
}
impl ToCss for ContentItem {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
ContentItem::Text(value) => write_css_string(dest, value),
ContentItem::Image(image) => image.to_css(dest),
ContentItem::Attr(attr) => attr.to_css(dest),
}
}
}
impl ToCss for AttrRef {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
dest.write_str("attr(")?;
dest.write_str(&self.name)?;
if !self.fallback.is_empty() {
dest.write_str(", ")?;
write_css_string(dest, &self.fallback)?;
}
dest.write_char(')')
}
}
#[cfg(test)]
#[allow(clippy::panic, clippy::expect_used)]
mod tests {
use std::assert_matches;
use super::*;
fn parse(input: &str) -> ContentValue {
ContentValue::from_str(input).expect("parse")
}
#[test]
fn parses_none_and_normal() {
assert_eq!(parse("none"), ContentValue::None);
assert_eq!(parse("normal"), ContentValue::Normal);
}
#[test]
fn parses_single_string() {
let ContentValue::Items(items) = parse("\"hello\"") else {
panic!("expected items");
};
assert_eq!(items.len(), 1);
assert_eq!(items[0], ContentItem::Text("hello".into()));
}
#[test]
fn parses_multiple_strings_as_list() {
let ContentValue::Items(items) = parse("\"a\" \"b\"") else {
panic!("expected items");
};
assert_eq!(items.len(), 2);
}
#[test]
fn parses_attr_without_fallback() {
let ContentValue::Items(items) = parse("attr(label)") else {
panic!("expected items");
};
let ContentItem::Attr(attr) = &items[0] else {
panic!("expected attr");
};
assert_eq!(&*attr.name, "label");
assert_eq!(&*attr.fallback, "");
}
#[test]
fn parses_attr_with_fallback() {
let ContentValue::Items(items) = parse("attr(label, \"unknown\")") else {
panic!("expected items");
};
let ContentItem::Attr(attr) = &items[0] else {
panic!("expected attr");
};
assert_eq!(&*attr.fallback, "unknown");
}
#[test]
fn parses_url_image() {
let ContentValue::Items(items) = parse("url(\"icon.png\")") else {
panic!("expected items");
};
assert_matches!(
&items[0],
ContentItem::Image(image) if matches!(**image, BackgroundImage::Url(_))
);
}
#[test]
fn parses_mixed_list() {
let ContentValue::Items(items) = parse("\"Prefix: \" attr(name) url(\"icon.png\")") else {
panic!("expected items");
};
assert_eq!(items.len(), 3);
}
#[test]
fn unsupported_function_is_rejected() {
assert!(ContentValue::from_str("counter(foo)").is_err());
assert!(ContentValue::from_str("\"prefix\" counter(foo)").is_err());
}
#[test]
fn unsupported_keyword_is_rejected() {
assert!(ContentValue::from_str("open-quote").is_err());
assert!(ContentValue::from_str("\"x\" close-quote").is_err());
}
}